チョコレートパフェのイラスト

フルスタック Web フレームワーク HonoX を使ってみる

HonoX は Hono と Vite をベースにしたフルスタック Web フレームワークです。Hono が提供するサーバーサイドやクライアントサイドの機能を使いつつ、ファイルベースルーティングや Islands Architecture などの新しい機能を使うことができます。

Warning

HonoX は 2024 年 2 月現在アルファステージとなっています。セマンティックバージョンに従わずに破壊的な変更が行われる可能性があります。

HonoX は HonoVite をベースにしたフルスタック Web フレームワークです。以下のような特徴があります。

  • ファイルベースのルーティング
  • 高速な SSR
  • BYOR(Bring Your Own Rerender):レンダリングエンジンを自由に選択できます
  • islands ハイドレーション
  • ミドルウェア:バックエンドは Hono として動作するため、多くの Hono のミドルウェア を使用できます

HonoX を始める

新しい HonoX のプロジェクトを作成するためには、hono-create コマンドを使用します。

npm create hono@latest

? Which template do you want to use? という質問に対して x-basic を選択します。

create-hono version 0.3.2
✔ Target directory … hono-blog
? Which template do you want to use? › - Use arrow-keys. Return to submit.
↑ cloudflare-pages
cloudflare-workers
deno
fastly
lambda-edge
netlify
nextjs
nodejs
vercel
❯ x-basic
 

hono-create コマンドが完了すると、以下のようなディレクトリ構造が作成されます。

.
├── app
│   ├── islands
│   │   ├── counter.tsx
│   ├── routes
│ │ ├── \_renderer.tsx
│   │   └── index.tsx
│   ├── client.ts
│   ├── global.d.ts
│   └── server.ts
├── package.json
├── tsconfig.json
└── vite.config.ts
 

作成されたディレクトリに移動して、npm installnpm run dev を実行します。

cd hono-blog
npm install
npm run dev

http://localhost:5173 にアクセスすると、以下のような画面が表示されます。

ブラウザのスクリーンショット、Hello, Hono! と表示されている。その下には 3 と increment ボタンが表示されている。

プロジェクトの構造

HonoX のプロジェクトは以下のような構造になっています。

  • app:サーバーのエントリーポイント、やクライアントのすべてのコードが含まれています
  • app/islands:インタラクションを行う islands コンポーネントを含むディレクトリ
  • app/routes:ファイルベースのルーティングのためのディレクトリ。index.tsx/ に、about.tsx/about にマッチする
  • app/routes/_renderer.tsx:Hono の JSX Renderer Middleware が呼ばれるファイル。レイアウトとして機能する
  • app/client.ts:クライアントエントリーポイント
  • app/server.ts:サーバーエントリーポイント

クライアントルートを作成する

それでは、どのようにページを作成しているのか見ていきましょう。app/routes/index.tsx は以下のようになっています。

app/routes/index.tsx
import { css } from 'hono/css'
import { createRoute } from 'honox/factory'
import Counter from '../islands/counter'
 
const className = css`
  font-family: sans-serif;
`
 
export default createRoute((c) => {
  const name = c.req.query('name') ?? 'Hono'
  return c.render(
    <div class={className}>
      <h1>Hello, {name}!</h1>
      <Counter />
    </div>,
    { title: name }
  )
})

HonoX ではそれぞれのルートで Handler | MiddlewareHandler の配列を default export する必要があります。createRoute はそのためのヘルパー関数です。createRoute に渡すコールバック関数では引数として Context を受け取ります。これは Hono の Context と同じです。ここでは c.req.query('name') でクエリパラメータを取得しています。

c.render 関数を使ってコンポーネントをレンダリングしています。ここで使われているのは HonoX のデフォルトのレンダリングエンジンである hono/jsx です。基本的には React と同じように使えます。

c.render の第 2 引数にはページのカスタムデータを指定できます。ここでは title を指定して、ページのタイトルをカスタマイズしています。ここで指定した title_renderer.tsx で引数として受け取り使われます。

app/routes/_renderer.tsx
import { Style } from 'hono/css'
import { jsxRenderer } from 'hono/jsx-renderer'
import { Script } from 'honox/server'
 
export default jsxRenderer(({ children, title }) => {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
        <Script src="/app/client.ts" />
        <Style />
      </head>
      <body>{children}</body>
    </html>
  )
})

c.render の第 2 引数の型定義は app/global.d.ts で以下のようになっています。

app/global.d.ts
import {} from 'hono'
 
type Head = {
  title?: string
}
 
declare module 'hono' {
  interface Env {
    Variables: {}
    Bindings: {}
  }
  interface ContextRenderer {
    (content: string | Promise<string>, head?: Head): Response | Promise<Response>
  }
}

title 以外の descriptionkeywords などのメタデータを指定したい場合には、ここの型定義と _renderer.tsx を変更する必要があります。

CSS ライブラリとして hono/css が使われています。これは Hono のビルドインの CSS in JS ライブラリです。CSS をテンプレートリテラルで書くことができます。

app/routes/index.tsx
import { css } from 'hono/css'
 
const className = css`
  font-family: sans-serif;
`

hono/css を使う場合には、_renderer.tsx で必ず <Style> コンポーネントを挿入する必要があります。

app/routes/_renderer.tsx
import { Style } from 'hono/css'
import { jsxRenderer } from 'hono/jsx-renderer'
import { Script } from 'honox/server'
 
export default jsxRenderer(({ children, title }) => {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
        <Script src="/app/client.ts" />
        <Style />
      </head>
      <body>{children}</body>
    </html>
  )
})

islands コンポーネント

app/routes/index.tsx で使われている <Counter> コンポーネントは islands コンポーネントです。islands コンポーネントとは Islands Architecture に基づいて作られたコンポーネントです。

Islans Architecture とは、ページ内でサーバー側でレンダリングされる静的な部分とインタラクティブな部分を分離する手法です。以下の図のように、サーバーでレンダリングされる大部分を海に対して、インタラクティブな部分は独立した島に見立てられることから Islands Architecture と呼ばれています。

Islands Architecture - JASON Format より引用。

Islands Architecture のメリットはパフォーマンスです。インタラクションのために必要なクライアント JavaScript の読み込みを待たずに、サーバーでレンダリングされた静的な部分をすぐに表示できることです。従来の SPA ではすべての JavaScript が読み込まれるまでページが表示されないため、ユーザーが待たされることがありました。JavaScript が必要ない大部分を先に表示することで、ユーザーエクスペリエンスを向上させることができます。

またそれぞれの islands コンポーネント同士も独立しているため、例えば優先度の高い Header は多くの JavaScript を必要とする Image Carousel の読み込みを待つ必要がありません。

Islands Architecture は HonoX の他にも AstroFresh などでも採用されています。

HonoX では islands コンポーネントを app/islands ディレクトリに配置します。app/islands/counter.tsx は以下のようになっています。

app/islands/counter.tsx
import { useState } from 'hono/jsx'
 
export default function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

hono/jsxuseState は React の useState と同じように使えます。<button> がクリックされると count がインクリメントされるいった簡単な状態管理を行っているコンポーネントです。

hono/jsx では以下のフックをサポートしています。

  • useContext
  • useEffect
  • useState
  • useCallback
  • use
  • startTransition
  • useDeferredValue
  • useMemo
  • useLayoutEffect
  • Memo
  • isValidElement

この <Counter> コンポーネントは routes/ ディレクトリ内のファイルから使われると、まずはサーバー側でレンダリングされます。その後クライアント側に JavaScript が送信されインタラクションが可能になります。

ブログアプリケーションのチュートリアル

HonoX を使って簡単なブログアプリケーションを作成してみましょう。以下のような機能を持つブログアプリケーションを作成します。

  • 記事の作成
  • 記事の一覧表示
  • 記事の詳細表示
  • 記事の削除

完成したコードは以下のリポジトリで確認できます。

レイアウトコンポーネントの作成

ページを作り始める前に、まずはそれぞれのページで共通して使うレイアウトコンポーネントを作成しましょう。app/components ディレクトリを作成し、app/components/Layout.tsx を作成します。

app/components/layout.tsx
import { css, cx } from "hono/css";
import type { FC } from "hono/jsx";
import { useRequestContext } from "hono/jsx-renderer";
 
const className = css``;
 
const headerClass = css`
  border-bottom: 1px solid #ddd;
  display: flex;
  gap: 1rem;
  align-items: center;
  padding: 0.5rem 1rem;
  justify-content: space-between;
`;
 
const titleClass = css`
  font-size: 1.5rem;
  margin: 0;
`;
 
const navClass = css`
  display: flex;
  gap: 1rem;
`;
 
// & nesting selector https://developer.mozilla.org/en-US/docs/Web/CSS/Nesting_selector
// を使って疑似クラスをそ指定する
const linkClass = css`
  &:hover {
    background-color: #f4f4f4;
  }
  text-decoration: none;
  padding: 0.5rem 1rem;
  color: #262626;
  border-radius: 3px;
`;
 
const activeLinkClass = css`
  background-color: #f4f4f4;
`;
 
const containerClass = css`
  max-width: 800px;
  margin: 0 auto;
  padding: 1rem;
`;
 
const links = [
  { href: "/articles", text: "Articles" },
  { href: "/articles/create", text: "Create" },
];
 
export const Layout: FC = ({ children }) => {
  const c = useRequestContext();
  const current = c.req.path;
  return (
    <div class={className}>
      <header class={headerClass}>
        <h1 class={titleClass}>
          <a href="/" class={linkClass}>
            HonoX Blog 🔥
          </a>
        </h1>
        <nav class={navClass}>
          {links.map((link) => (
            <a
              href={link.href}
              // cx は clsx のように複数のクラスを結合する関数
              class={cx(linkClass, current === link.href && activeLinkClass)}
            >
              {link.text}
            </a>
          ))}
        </nav>
      </header>
      <main class={containerClass}>{children}</main>
    </div>
  );
};

いくつかポイントを見ていきましょう。app/routes ディレクトリ配下にあるファイルでは createRoute の引数からコンテキスト(c)を受け取っていました。ページコンポーネントから使われることが前提のコンポーネントでは、useRequestContext を使って直接コンテキストを取得できます。

ここでは、現在のパスを取得するために c.req.path の値を使っています。

app/components/Layout.tsx
import { useRequestContext } from "hono/jsx-renderer";
 
// ...
 
export const Layout: FC = ({ children }) => {
  const c = useRequestContext();
  const current = c.req.path;
  // ...
};

現在の値と一致する場合には activeLinkClass を適用するようにしています。条件によってクラス名をつける場合には cx 関数を使うとスッキリとしたコードになります。cx 関数は classnamesclsx と同じように、複数のクラスを結合する関数です。複数の引数を受け取り、falsy な値を除外して結合します。

app/components/Layout.tsx
import { css, cx } from "hono/css";
 
// ...
 
export const Layout: FC = ({ children }) => {
  const c = useRequestContext();
  const current = c.req.path;
  return (
    // ...
    <nav class={navClass}>
      {links.map((link) => (
        <a
          href={link.href}
          class={cx(linkClass, current === link.href && activeLinkClass)}
        >
          {link.text}
        </a>
      ))}
    </nav>
    // ...
  );
};

作成したレイアウトコンポーネントは app/routes/_renderer.tsx で使われます。jsxRenderer 関数はレイアウトとして機能するミドルウェアです。

app/routes/_renderer.tsx
import { Style } from "hono/css";
import { jsxRenderer, useRequestContext } from "hono/jsx-renderer";
import { Script } from "honox/server";
import { Layout } from "../components/Layout";
 
export default jsxRenderer(({ children, title }) => {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
        <Script src="/app/client.ts" />
        <Style />
      </head>
      <body>
        <Layout>{children}</Layout>
      </body>
    </html>
  );
});

続いてグローバル CSS を追加します。hono/css を使ってグローバル CSS を追加するには以下の 2 つの方法があります。

  • :hono-global 疑似セレクタを使う
  • <Style> コンポーネントの中で css 関数を使う

今回は後者の方法を使ってグローバル CSS を追加してみましょう。app/routes/_renderer.tsx に以下のように追記します。

app/routes/_renderer.tsx
import { Style, css } from "hono/css";
import { jsxRenderer } from "hono/jsx-renderer";
import { Script } from "honox/server";
import { Layout } from "../components/Layout";
 
export default jsxRenderer(({ children, title }) => {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
        <Script src="/app/client.ts" />
        <Style>
          {css`
            html {
              font-size: 16px;
              font-family: system-ui, sans-serif;
            }
            body {
              min-height: 100vh;
              color: #262626;
              background-color: #f4f4f4;
            }
            *,
            *::before,
            *::after {
              box-sizing: border-box;
              margin: 0;
              padding: 0;
            }
          }`}
        </Style>
      </head>
      <body>
        <Layout>{children}</Layout>
      </body>
    </html>
  );
});

ここまでの実装が完了したら、http://localhost:5173 にアクセスして、レイアウトと CSS が適用されていることを確認しましょう。

記事の作成

まずは記事の作成機能を作成します。app/routes ディレクトリに articles/create.tsx を作成します。このファイルは /articles/create というパスにマッチするルートです。

app/routes/articles/create.tsx
import { css } from "hono/css";
import { createRoute } from "honox/factory";
import type { FC } from "hono/jsx";
 
const titleClass = css`
  font-size: 1.5rem;
  margin-bottom: 1rem;
`;
 
const formClass = css`
  display: flex;
  flex-direction: column;
  gap: 1rem;
`;
 
const labelClass = css`
  display: flex;
  flex-direction: column;
  font-weight: bold;
  gap: 0.5rem;
`;
 
const inputClass = css`
  width: 100%;
  padding: 0.5rem 0.25em;
  border-radius: 3px;
  border: 2px solid #ddd;
`;
 
const textareaClass = css`
  width: 100%;
  border: 2px solid #ddd;
  border-radius: 3px;
  padding: 0.5rem;
  min-height: 10rem;
  resize: vertical;
`;
 
const buttonClass = css`
  padding: 0.5rem;
  background-color: #007bff;
  color: white;
  border: none;
  cursor: pointer;
  border-radius: 999px;
`;
 
const Page: FC = () => {
  return (
    <div>
      <h1 class={titleClass}>Create an article</h1>
      <form class={formClass} method="post">
        <label class={labelClass}>
          Title
          <input name="title" class={inputClass} type="text" />
        </label>
        <label class={labelClass}>
          Content
          <textarea name="content" class={textareaClass} />
        </label>
        <button class={buttonClass} type="submit">
          Create
        </button>
      </form>
    </div>
  );
};
 
export default createRoute((c) => {
  return c.render(<Page />, {
    title: "Create an article",
  });
});

タイトルとコンテンツを入力するための簡単なフォームです。http://localhost:5173/articles/create にアクセスして、フォームが表示されることを確認しましょう。

POST リクエストの処理

続いてフォームの送信を処理するために、app/routes/articles/create.tsx で POST リクエストを受け取るためのハンドラを追加します。

export defaultHandler | MiddlewareHandler の配列(createRoute)を返した場合にはそのハンドラは GET リクエストのハンドラとして使われます。GET リクエスト以外のリクエストを受け取るためには、対応するメソッド名の変数を名前付き export します。

今回の例では POST リクエストを受け取るために POST という名前で変数を定義して export します。

app/routes/articles/create.tsx
export const POST = createRoute(async (c) => {
  const body = await c.req.formData();
 
  console.log("title:", body.get("title"));
  console.log("content:", body.get("content"));
 
  return c.redirect("/articles");
});

リクエストボディは c.req.formData() メソッドで FormData オブジェクトとして受け取ります。リクエストを受け取ったら c.redirect("/articles") で記事の一覧画面へリダイレクトします(まだ記事の一覧画面は作成していないので、404 画面が表示されます)。

フォームになにか入力してサブミットボタンを押した時に、コンソールに入力した値が表示されることを確認しましょう。

title: フルスタック Web フレームワーク HonoX を使ってみる
content: HonoX は [Hono](https://hono.dev/)[Vite](https://vitejs.dev/) をベースにしたフルスタック Web フレームワークです。 以下のような特徴があります。

ミドルウェアによるバリデーション

HonoX では Hono のミドルウェアを使うことができます。ここでは Zod Validator ミドルウェアを使ってフォームの値のバリデーションを行ってみましょう。

以下のコマンドでミドルウェアをインストールします。

npm install @hono/zod-validator zod

ミドルウェアを使うには createRoute() の第 1 引数にミドルウェアを渡します。ミドルウェアはルートハンドラと同じように Handler | MiddlewareHandler の配列を返す関数です。

app/routes/articles/create.tsx
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
 
const Article = z.object({
  title: z.string().min(1).max(255),
  content: z.string().min(1),
});
 
export const POST = createRoute(
  zValidator("form", Article, (result, c) => {
    if (result.success) {
      // TODO DB に保存する
      console.log(result.data);
      return c.redirect("/articles");
    }
 
    return c.render(<Page />, {
      title: "Create an article",
    });
  })
);

まずは zod でバリデーションスキーマを定義します。Articletitlecontent という 2 つのプロパティを持つオブジェクトです。title は 1 文字以上 255 文字以下、content は 1 文字以上であることをバリデーションしています。

次に zValidator ミドルウェアを使ってバリデーションを行います。zValidator は 3 つの引数を受け取ります。

  1. バリデーションの名前:バリデーションのターゲット。form の他に json, query, header, param, cookie がある
  2. バリデーションスキーマ:zod で定義したバリデーションスキーマ
  3. コールバック関数:通常のルートハンドラに追加してバリデーションの結果を引数として受け取る関数

コールバック関数の第 1 引数のオブジェクトは Zod でバリデーションした結果です。result.successtrue の場合にはバリデーションに成功しています。result.data にはバリデーションに成功したデータが入っています。バリデーションに失敗した場合には再度フォームを表示するようにしています。

バリデーションに失敗した場合にはエラーメッセージを表示し、前回入力した値をフォームに表示するように変更してみましょう。まずは <Page> コンポーネントでバリデーション結果を Props として受け取れるようにします。

app/routes/articles/create.tsx
type Data = {
  title: {
    value: string;
    error: string[] | undefined;
  };
  content: {
    value: string;
    error: string[] | undefined;
  };
};
 
type Props = {
  data?: Data;
};
 
const Page: FC<Props> = ({ data }) => {
  // ...
};

この data Props が存在する場合には、フォームにエラーメッセージと前回入力した値を表示するように変更します。

app/routes/articles/create.tsx
const errorClass = css`
  color: red;
  font-size: 0.75rem;
`;
 
const Page: FC<Props> = ({ data }) => {
  return (
    <div>
      <h1 class={titleClass}>Create an article</h1>
      <form class={formClass} method="post">
        <label class={labelClass}>
          Title
          <input
            name="title"
            class={inputClass}
            type="text"
            value={data?.title.value}
          />
        </label>
        {data?.title.error &&
          data.title.error.map((error) => <p class={errorClass}>{error}</p>)}
        <label class={labelClass}>
          Content
          <textarea
            name="content"
            class={textareaClass}
            value={data?.content.value}
          />
        </label>
        {data?.content.error &&
          data.content.error.map((error) => <p class={errorClass}>{error}</p>)}
        <button class={buttonClass} type="submit">
          Create
        </button>
      </form>
    </div>
  );
};

そして、POST リクエストのハンドラでバリデーションに失敗した場合には、c.render()<Page> コンポーネントをレンダリングする際に Props としてバリデーション結果を渡すように変更します。

app/routes/articles/create.tsx
export const POST = createRoute(
  zValidator("form", Article, (result, c) => {
    if (result.success) {
      // TODO DB に保存する
      console.log(result.data);
      return c.redirect("/articles");
    }
 
    const { title, content } = result.data;
    const data: Data = {
      title: {
        value: title,
        error: result.error.flatten().fieldErrors.title,
      },
      content: {
        value: content,
        error: result.error.flatten().fieldErrors.content,
      },
    };
 
    return c.render(<Page data={data} />, {
      title: "Create an article",
    });
  })
);

フォームの値を空にしてサブミットボタンを押してみると、エラーメッセージが表示されることを確認できます。

データの保存

後回しにしていたデータを保存する処理を実装します。ここでは簡単のため、JSON ファイルに保存することにします。data/articles.json というファイルに記事のデータを保存します。

mkdir data
echo '[]' > data/articles.json

app/lib/db.ts というファイルを作成し、記事を保存するための関数を実装します。

app/lib/db.ts
import fs from "fs/promises";
 
export type Article = {
  id: string;
  title: string;
  content: string;
  created_at: string;
  updated_at: string;
};
 
export const createArticle = async ({
  title,
  content,
}: Pick<Article, "title" | "content">) => {
  const articlesJSON = await fs.readFile("./data/articles.json", {
    encoding: "utf-8",
  });
  const articles: Article[] = JSON.parse(articlesJSON);
  const id = crypto.randomUUID();
  const created_at = new Date().toISOString();
  const updated_at = created_at;
  const article: Article = { id, title, content, created_at, updated_at };
  articles.push(article);
  await fs.writeFile("./data/articles.json", JSON.stringify(articles));
 
  return article;
};

作成した createArticle 関数を app/routes/articles/create.tsx で使うように修正します。

app/routes/articles/create.tsx
export const POST = createRoute(
  zValidator("form", Article, async (result, c) => {
    if (result.success) {
      const { title, content } = result.data;
      await createArticle({ title, content });
 
      return c.redirect("/articles");
    }
 
    // ...
  })
);

ここまでの実装が完了したら、フォームに何か入力して記事を作成してみましょう。うまくいけば、data/articles.json に記事が追加されているはずです。

記事の一覧表示

記事の作成ができたら、次は記事の一覧表示機能を作成します。今回はまず記事の一覧を取得する関数から実装します。app/lib/db.tsgetArticles という関数を追加します。

app/lib/db.ts
export const getArticles = async () => {
  const articlesJSON = await fs.readFile("./data/articles.json", {
    encoding: "utf-8",
  });
  return JSON.parse(articlesJSON);
};

app/routes/articles/index.tsx というファイルを作成し、記事の一覧を表示するページを作成します。

app/routes/articles/index.tsx
import { FC } from "hono/jsx";
import { createRoute } from "honox/factory";
import { Article, getArticles } from "../../lib/db";
import { css } from "hono/css";
 
type Props = {
  articles: Article[];
};
 
const titleClass = css`
  font-size: 1.5rem;
  margin-bottom: 1rem;
`;
 
const cards = css`
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 1rem;
`;
 
const card = css`
  border: 1px solid #ddd;
  border-radius: 3px;
  padding: 1rem;
  width: 100%;
  list-style: none;
`;
 
const Page: FC<Props> = ({ articles }) => {
  return (
    <div>
      <h1 class={titleClass}>Articles</h1>
      <ul class={cards}>
        {articles.map((article) => (
          <li class={card}>
            <a href={`/articles/${article.id}`}>{article.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
};
 
export default createRoute(async (c) => {
  const articles = await getArticles();
  return c.render(<Page articles={articles} />, { title: "Articles" });
});

記事の一覧をサーバーサイドで取得して表示してる簡単なページです。http://localhost:5173/articles にアクセスして、記事の一覧が表示されることを確認しましょう。

記事の詳細表示

続いて記事の詳細画面を作りましょう。ID を指定して記事を取得する関数を app/lib/db.ts に追加します。

app/lib/db.ts
 
export const getArticleById = async (id: string) => {
  const articlesJSON = await fs.readFile("./data/articles.json", {
    encoding: "utf-8",
  });
  const articles = JSON.parse(articlesJSON) as Article[];
  return articles.find((article) => article.id === id);
};

記事のマークダウンを変換するために必要な @markdoc/markdoc パッケージをインストールします。

npm i @markdoc/markdoc

マークダウンを HTML に変換する関数を app/lib/markdown.ts というファイルに追加します。

app/lib/markdown.ts
import Markdoc from "@markdoc/markdoc";
 
export const parseMarkdown = (source: string) => {
  const ast = Markdoc.parse(source);
  const content = Markdoc.transform(ast);
 
  const html = Markdoc.renderers.html(content);
  return html;
};

app/routes/articles/[id].tsx というファイルを作成し、記事の詳細を表示するページを作成します。[] で囲まれた部分は動的なパスパラメータを表します。[id].tsx/articles/:id というパスにマッチするルートです。

app/routes/articles/[id].tsx
import { FC } from "hono/jsx";
import { Article, getArticleById } from "../../lib/db";
import { css } from "hono/css";
import create from "./create";
import { createRoute } from "honox/factory";
import { parseMarkdown } from "../../lib/markdown";
 
const cardClass = css`
  border: 1px solid #ddd;
  border-radius: 3px;
  padding: 1rem;
  width: 100%;
`;
 
const titleClass = css`
  font-size: 1.5rem;
  margin-bottom: 1rem;
`;
 
const contentClass = css`
  margin-top: 2rem;
 
  p + p {
    margin-top: 1rem;
  }
 
  ul {
    margin-top: 1rem;
  }
 
  ul li {
    list-style: disc;
    margin-left: 1rem;
    margin-bottom: 0.5rem;
  }
`;
 
type Props = {
  article: Article;
  content: string;
};
 
const Page: FC<Props> = ({ article, content }) => {
  return (
    <article class={cardClass}>
      <header>
        <h1 class={titleClass}>{article.title}</h1>
      </header>
      <div
        class={contentClass}
        id="contents"
        dangerouslySetInnerHTML={{ __html: content }}
      />
    </article>
  );
};
 
export default createRoute(async (c) => {
  const { id } = c.req.param();
  const article = await getArticleById(id);
  if (!article) {
    return c.notFound();
  }
 
  const content = parseMarkdown(article.content);
 
  return c.render(<Page article={article} content={content} />, {
    title: article.title,
  });
});

パスパラメータを取得するために c.req.param() を使っています。もし指定された ID の記事が存在しない場合には c.notFound() を使って 404 エラーを返します。

app/routes/articles/[id].tsx
export default createRoute(async (c) => {
  const { id } = c.req.param();
  const article = await getArticleById(id);
  if (!article) {
    return c.notFound();
  }
 
  const content = parseMarkdown(article.content);
 
  return c.render(<Page article={article} content={content} />, {
    title: article.title,
  });
});

hono/jsx では dangerouslySetInnerHTML を使って HTML をエスケープせずにレンダリングできます。

記事の一覧画面から詳細画面に遷移して、記事の内容が表示されることを確認しましょう。

404 ページ

記事が存在しない時に表示される 404 ページはデフォルトではそっけない画面が表示されます。この画面をカスタマイズするために app/routes/_404.tsx というファイルを作成します。

app/routes/_404.tsx
import { NotFoundHandler } from "hono";
 
const handler: NotFoundHandler = (c) => {
  return c.render(<h1>Sorry, Not Found...</h1>);
};
 
export default handler;

エラーページ

404 ページと同じように、エラーが発生した際のエラーページをカスタマイズするために app/routes/_error.tsx というファイルを作成します。

app/routes/_error.tsx
// app/routes/_error.ts
import { ErrorHandler } from 'hono'
 
const handler: ErrorHandler = (e, c) => {
  return c.render(<h1>Error! {e.message}</h1>)
}
 
export default handler

記事の削除

最後の集大成として、記事の削除機能を作成します。まずは app/lib/db.tsdeleteArticle という関数を追加します。

app/lib/db.ts
export const deleteArticle = async (id: string) => {
  const articlesJSON = await fs.readFile("./data/articles.json", {
    encoding: "utf-8",
  });
  const articles = JSON.parse(articlesJSON) as Article[];
  const newArticles = articles.filter((article) => article.id !== id);
  await fs.writeFile("./data/articles.json", JSON.stringify(newArticles));
};

記事を削除するためのボタンを追加しましょう。ここではボタンをクリックした時に削除するかどうか確認するダイアログを表示し、OK をクリックしたときには <form> でリクエストを送信して削除を実行するようにします。

ボタンをクリックした時にダイアログを表示するというインタラクションを含むため、このコンポーネントは Islands コンポーネントとして実装する必要があります。app/islands/DeleteButton.tsx というファイルを作成し、以下のように実装します。

app/islands/DeleteButton.tsx
import { css } from "hono/css";
import { FC, useRef } from "hono/jsx";
 
const dialogClass = css`
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border: 1px solid #ddd;
  padding: 1rem;
  border-radius: 3px;
  background-color: white;
 
  &::backdrop {
    background-color: rgba(0, 0, 0, 0.5);
  }
`;
 
const dialogButtons = css`
  display: flex;
  gap: 1rem;
  justify-content: flex-end;
  margin-top: 1rem;
`;
 
const cancelButton = css`
  padding: 0.5rem;
  background-color: #ddd;
  border: none;
  border-radius: 3px;
  cursor: pointer;
`;
 
const buttonClass = css`
  background-color: red;
  color: white;
  border: none;
  border-radius: 3px;
  padding: 0.5rem 1rem;
  cursor: pointer;
`;
 
export default function DeleteButton({ articleId }: { articleId: string }) {
  const dialogRef = useRef<HTMLDialogElement | null>(null);
 
  const handleClickDelete = () => {
    dialogRef.current?.showModal();
  };
 
  return (
    <div>
      <dialog class={dialogClass} ref={dialogRef}>
        <form method="POST" action={`/articles/${articleId}/delete`}>
          <p>Are you sure you want to delete this article?</p>
          <div class={dialogButtons}>
            <button value="cancel" formmethod="dialog" class={cancelButton}>
              Cancel
            </button>
            <button type="submit" class={buttonClass}>
              OK
            </button>
          </div>
        </form>
      </dialog>
      <button type="button" class={buttonClass} onClick={handleClickDelete}>
        Delete
      </button>
    </div>
  );
}

ここでは HTML 標準の <dialog> 要素を使ってダイアログを表示しています。ダイアログを表示するには、ダイアログ要素の ref を取得し、ボタンがクリックされた時に showModal() メソッドを呼び出します。

ダイアログのキャンセルボタンが押された時に閉じる処理は、form 要素を使って実装できます。フォームのメソッドが dialog の場合には、サブミットボタンがダイアログを閉じるボタンとして機能するのです。

OK ボタンが押されたときには、/articles/:id/delete に POST リクエストを送信するようにしています。app/routes/articles/[id]/delete.ts というファイルを作成し、記事の削除を行うハンドラを実装します。

app/routes/articles/[id]/delete.ts
import { createRoute } from "honox/factory";
import { deleteArticle } from "../../../lib/db";
 
export const POST = createRoute(async (c) => {
  const { id } = c.req.param();
  await deleteArticle(id);
 
  return c.redirect("/articles");
});

最後に、記事の一覧画面に DeleteButton コンポーネントを追加します。

app/routes/articles/index.tsx
const card = css`
  border: 1px solid #ddd;
  border-radius: 3px;
  padding: 1rem;
  width: 100%;
  list-style: none;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
`;
 
const Page: FC<Props> = ({ articles }) => {
  return (
    <div>
      <h1 class={titleClass}>Articles</h1>
      <ul class={cards}>
        {articles.map((article) => (
          <li class={card}>
            <a href={`/articles/${article.id}`}>
              {article.title}
            </a>
            <DeleteButton articleId={article.id} />
          </li>
        ))}
      </ul>
    </div>
  );
};

記事の一覧画面に削除ボタンが表示されています。削除ボタンをクリックしてダイアログが表示され、OK ボタンを押すと記事が削除されることを確認しましょう。

感想

HonoX を使ってフルスタック簡単なブログアプリケーションを作ってみました。多くの API は Hono と同じように使うことができるため、すでに Hono を使ったことがある人にとっては馴染みやすいフレームワークだと思います。

Next.js と比較して、軽量のフレームワークとして使用する分には十分な機能を持っていると感じました。アルファ版であるため行いくつか不安定な部分があるものの(HMR で追加したルートが反映されないなど)、今後のバージョンアップでのさらなる進化を期待しています。

参考


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事