torikatsu923.dev

サイト内検索

    このブログの構成 — Astro 7 + Cloudflare Workers で JS ゼロを守る

    はてなブログから移ってきたこのブログの構成メモ。Content Collections のスキーマ、Tokyo Night に揃えた配色、そして初期ロードの JavaScript を 1 バイトも配信しないための判断を書く。

    はてなブログからセルフホストに移した。目標は Lighthouse のパフォーマンススコア 100 で、機能とスコアが競合したらスコアを優先する、という一本の基準だけ決めて組み立てている。その基準から自然に落ちてきた構成をメモしておく。

    全体像

    レイヤー 選定
    フレームワーク Astro 7(全ページ SSG)
    ホスティング Cloudflare Workers Static Assets
    CSS Tailwind CSS v4 + daisyUI 5
    コードハイライト Shiki(Astro 標準)

    記事は Git がソース・オブ・トゥルース。MDX を書いて main にマージしたら公開される。データベースは持っていない。

    Content Collections は src/content.config.ts

    Astro 5 で Content Layer API が入り、設定ファイルの置き場所が src/content/config.ts から src/content.config.ts へ移った。旧い場所は Astro 6 で完全に削除されていて、置いたままにするとビルドがエラーで落ちる。ネット上の記事はまだ旧い場所を書いているものが多いので注意したい。

    import { defineCollection, z } from 'astro:content';
    import { glob } from 'astro/loaders';
    
    export const TAGS = ['astro', 'cloudflare', 'typescript'] as const;
    
    const blog = defineCollection({
      loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
      schema: z.object({
        title: z.string(),
        slug: z.string().regex(/^[a-z0-9-]+$/),
        pubDate: z.date(),
        updatedDate: z.date().optional(),
        description: z.string(),
        tags: z.array(z.enum(TAGS)),
        draft: z.boolean().default(false),
      }),
    });
    
    export const collections = { blog };

    ここで効いているのは tagsz.string() にしていないところ。タグを自由文字列にすると、typescriptTypeScript が別タグとして生えたり、タイプミスで記事 1 本しかない幽霊タグができたりする。enum にしておくと、そういう記事はビルドが通らない。書いた本人が気づく前に CI が止めてくれる。

    slug を必須にしてタイトルからの自動生成をやめたのも同じ発想で、URL は記事の一番外側の契約なので、フレームワークの都合で勝手に変わってほしくない。はてなブログから移してくる記事の URL を維持するときにも、この形が素直だった。

    draft の扱い

    下書きは本番ビルドからだけ落とす。dev サーバーでは見えてほしいので、フィルタの条件を環境で分けている。

    export async function getPublishedPosts(): Promise<Post[]> {
      const posts = await getCollection(
        'blog',
        ({ data }) => import.meta.env.PROD !== true || data.draft !== true,
      );
    
      return posts.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
    }

    一覧ページと詳細ページの getStaticPaths が両方ともこの関数を通るので、本番では下書きの HTML そのものが生成されない。URL を直接叩かれても 404 になる。

    初期ロードの JavaScript はゼロ

    ここがこの構成のいちばん尖っているところで、配信している JS が 1 バイトもない。

    そうなるように削った判断が 2 つある。

    1 つはテーマ切り替えをやめたこと。ダークテーマ 1 つだけにして、<html data-theme="dark"> を静的に書いている。テーマ切り替えを入れると、localStorage を読んで初期テーマを当てるインラインスクリプトが head から外せなくなる。あれを消せるのは大きい。

    もう 1 つはリンクの先読み。Astro の prefetchexperimental.clientPrerender はどちらも全ページに 3KB ほどの実行 JS を注入する。代わりに Speculation Rules API を素で書いた。

    <script type="speculationrules" is:inline set:html={JSON.stringify({
      prerender: [{ where: { href_matches: '/posts/*' }, eagerness: 'moderate' }],
    })} />

    type="speculationrules" は実行スクリプトではなく宣言的な JSON で、ブラウザはパースするだけ。スクリプト実行コストはゼロだし、JS のバイト数にも計上されない。未対応のブラウザはこの要素を単に無視するので、先読みされないだけで壊れない。eagernessmoderate にしてある。eager にすると一覧ページを開いただけで全記事を取りに行って帯域を無駄にする。

    配色は Tokyo Night に寄せる

    daisyUI 5 のテーマ変数へ、folke/tokyonight.nvim の night パレットをそのまま流し込んでいる。daisyUI 5 は色を oklch で持つ設計なので、上流の hex を厳密変換して置いた。

    @plugin "daisyui/theme" {
      name: "dark";
      default: true;
      color-scheme: dark;
    
      --color-base-100: oklch(22.629% 0.0214 280.49);    /* #1a1b26 bg */
      --color-base-content: oklch(84.557% 0.0611 274.76); /* #c0caf5 fg */
      --color-primary: oklch(71.898% 0.1322 264.20);     /* #7aa2f7 blue */
    }

    Shiki のテーマにも同じ上流の tokyo-night を指定している。ダーク固定なので dual theme を使う必要がなく、data-theme へ橋渡しする CSS も要らない。サイトの地の色とコードブロックの背景がどちらも #1a1b26 になるので、記事の中でコードだけ浮かない。

    color-scheme: dark の宣言を忘れないのも地味に効く。これがないとスクロールバーやフォーム部品が白いままで、ページ遷移のたびに白い閃きが出る。

    コードフェンスの言語は許可リストで縛る

    上のコードブロックの左上に出ているファイル名は、フェンスに書いた title="..." から来ている。ただし Astro はこのメタ文字列を Shiki に渡すだけで、既定では HTML のどこにも出力しない。属性にすらならないので、そのままだと書いても消える。

    そこで Shiki のトランスフォーマで predata-code-title 属性に移し替え、CSS の ::before で描画している。JS は増えず、属性 1 つとスタイル数行で済む。

    言語のほうは許可リストで縛っている。ここで一度ハマったのが、shikiConfig.langs に言語 ID を並べても許可リストとしては機能しないという点。あれは「Shiki にバンドルされていない独自文法を足す」ための枠で、型も文法オブジェクトの配列であって文字列ではない。バンドル済みの言語は列挙しなくても使用時に遅延ロードされる。

    そこで強制はトランスフォーマ側に移した。

    transformers: [
      {
        name: 'enforce-allowed-langs',
        preprocess(_code, options) {
          const lang = options.lang;
          if (SPECIAL_CODE_LANGS.includes(lang) || ALLOWED_CODE_LANGS.includes(lang)) return;
          throw new Error(`[shiki] 許可されていないコードフェンスの言語です: "${lang}"`);
        },
      },
    ],

    preprocess はフェンスごとに必ず呼ばれるので、列挙外の言語はビルドが落ちる。ハイライトの見た目を揃えたいというより、記事ごとに言語がばらけて Shiki のバンドルが太るのを防ぐのが目的。

    デプロイ

    Workers Static Assets に置いているだけなので、コマンドは 2 つで足りる。

    npm run build
    npx wrangler deploy

    wrangler.jsonc に Worker スクリプト本体(main)は無く、assets だけを指定している。つまりデプロイされるのは dist/ の静的ファイルだけで、リクエストごとに動くコードは無い。

    {
      "assets": {
        "directory": "./dist",
        "html_handling": "force-trailing-slash",
        "not_found_handling": "404-page"
      }
    }

    html_handling は Astro 側の trailingSlash: 'always' と必ず一致させる。ここがずれると、/posts/blog-architecture へのアクセスに 301 が 1 回挟まる。リダイレクトは往復ぶんの RTT がまるごと LCP に乗るので、静的サイトでは無視できない差になる。

    キャッシュも同じ考え方で、_headers にはハッシュ付きアセットのぶんだけ書いた。

    /_astro/*
      Cache-Control: public, max-age=31536000, immutable

    /* の包括ルールは書かない。Workers の _headers はマッチしたルールを全部連結するので、/* を足すと /_astro/*Cache-Controlpublic, max-age=0, must-revalidate, public, max-age=31536000, immutable になって immutable が効かなくなる。HTML 側は Workers の既定値がちょうど望む値なので、そもそも指定しなくていい。

    これから

    次は Lighthouse の基準値を取る。その後に RSS と OGP 画像、Pagefind での検索と続く予定で、機能を足すたびにこの基準値と比べて、何がスコアを削ったのかを特定できるようにしておきたい。とくに検索は JS を持ち込む最初の機能になるので、ボタンを押すまでロードしない形にできるかどうかがそのまま判断材料になる。