本文へ移動
myowchick

Astroの一覧の順番はファイル名が決めていた

ツールのメタ情報をAstroのcontent collectionに移したら、ツール一覧の並びが移行前と変わっていた。

frontmatterのpublishedAtは1つも触っていない。並べ替えのコードも素直で、publishedAtの昇順に並べているだけ。

それでも変わる。原因は、同じ日付が並んだときに何が順番を決めるのかを、どこにも書いていなかったことだった。

移行で踏んだ細部を3つ、実測しながら並べておく。

同じ日付の並びはファイル名が決めている

並べ替えはこれだけ。

// src/lib/tools.ts
export async function getPublishedTools(): Promise<Tool[]> {
  const tools = await getCollection('tools')
  const published = import.meta.env.PROD ? tools.filter((tool) => !tool.data.draft) : tools
  return published.toSorted((a, b) => a.data.publishedAt.getTime() - b.data.publishedAt.getTime())
}

比較関数はpublishedAtしか見ていない。同じ日付どうしを比べれば0を返す。

toSortedは安定ソートだから、0が返ったペアは入力の順序のまま据え置かれる。仕様上そうなっているだけでなく、実際に測っても崩れない。

const arr = Array.from({ length: 1000 }, (_, i) => ({ i, key: i % 3 }))
const sorted = arr.toSorted((a, b) => a.key - b.key)
// 同じkeyの中でiが昇順のまま残っているか
// -> true

つまり同着グループの並びは、比較関数ではなくgetCollectionが返した順がそのまま決めている。

そして今のツールは15本あって、publishedAtは3日しかない。

publishedAt本数
2026-08-155
2026-08-236
2026-08-244

15本すべてが同着グループの中にいる。一覧の並びは、最初から最後までタイブレークだけで決まっていたことになる。

getCollection('tools')の返り順を、一時ページを立てて素のまま出してみた。

business-days, color-converter, color-vision, encoding-converter, exif-viewer,
hash-generator, jwt-decoder, metronome, pair-randomizer, qr-code, random-string,
regex-tester, string-length, unixtime-converter, wareki-converter

ファイル名の昇順だった。この配列を安定ソートに掛けた結果は、ビルドしたdist/tools/index.htmlのリンク順と1件ずつ一致する。

何が起きるかというと、ファイル名を変えるだけで一覧が動く。qr-code.mdxをzz-qr-code.mdxにリネームしたつもりで同じ計算をすると、こうなる。

ツール前後
qr-code3番目5番目
random-string4番目3番目
string-length5番目4番目

中身は1文字も変えていないのに3件動く。移行で並びが変わったのもこれで、publishedAtが決めていない部分がまるごとファイル名に委ねられていた。

その順序を保証しているものは無い

もっと悪いのは、ファイル名順であること自体が誰にも保証されていない点。

getCollectionの返り順は、globローダーが集めたファイルの順に由来する。ローダーはtinyglobbyを呼んでいて、tinyglobbyもその下のfdirも、返す前にsortを呼んでいない。手元のnode_modulesを検索してもsortの呼び出しは1件も出てこない。

残るのはreaddirがOSから返す順で、これはファイルシステムの都合。この環境では3回ビルドして3回とも辞書順で返ってきたけれど、それは測った結果そうだったというだけの話で、約束ではない1。

書くべきだったのは、比較関数の中に同着の決め方を置くことだった。

return published.toSorted(
  (a, b) =>
    a.data.publishedAt.getTime() - b.data.publishedAt.getTime() || a.id.localeCompare(b.id),
)

今の15本ではこれを入れても並びは1件も動かない。動かないことが分かっているのが大事で、次にファイルを増やしても減らしても、順番の理由がコードの中にある。

ついでに測ったこと。getCollectionは呼ぶたびに新しい配列を返す。同じページで2回呼んでa === bはfalse、要素もa[0] === b[0]でfalseだった。だからここはsortで破壊的に並べ替えても他所は壊れない。toSortedである必要は、今のところ無かった。

z.enumに配列をそのまま渡せない、はもう古かった

カテゴリの一覧はsrc/data/tool-categories.tsにas constで置いてあって、スキーマ側はそれを引き直している。

const toolCategorySlugs = TOOL_CATEGORIES.map((category) => category.slug) as [
  (typeof TOOL_CATEGORIES)[number]['slug'],
  ...(typeof TOOL_CATEGORIES)[number]['slug'][],
]

const tools = defineCollection({
  schema: z.object({
    category: z.enum(toolCategorySlugs),
    // ...
  }),
})

このasが付いているのは、zod 3のz.enumが非空タプルしか受け取らなかったから。mapの結果は普通の配列なので、そのまま渡すと落ちる。zod 3.25で実際に出るのはこれ。

error TS2769: No overload matches this call.
  Argument of type '("text" | "convert")[]' is not assignable to
  parameter of type 'readonly [string, ...string[]]'.
    Source provides no match for required element at position 0 in target.

ところが、今このリポジトリが入れているzod 4.4.3の型定義はこうなっている。

declare function _enum<const T extends readonly string[]>(
  values: T,
  params?: string | core.$ZodEnumParams,
): ZodEnum<util.ToEnum<T[number]>>

タプルではなくreadonly string[]で、const型パラメータが付いた。試しにキャストを外した版を並べてastro checkに掛けたら、エラーは出ず、推論される型も同じだった。

const slugs = TOOL_CATEGORIES.map((category) => category.slug)
export const naive = z.enum(slugs)
type A = z.infer<typeof naive>
export const check: A = 'nope'
error ts(2322): Type '"nope"' is not assignable to type
  '"dev" | "file" | "text" | "convert" | "random" | "datetime" | "life"'

キャストを外してもユニオンのまま絞り込まれている。つまりあのasは、zod 3時代の名残がそのまま移行してきただけだった。

しかもこのキャストは嘘をつける。TOOL_CATEGORIESが空になっても型は非空タプルを名乗り続けるし、zodはz.enum([])を実行時にも受け入れる。作られるのは何もparseできないスキーマで、エラー文はこうなる。

Invalid option: expected one of

expected one ofの後ろが空。カテゴリを1つも通さないスキーマがビルドを通ってしまうので、消せるキャストは消しておくに越したことはない。

application/ld+jsonのscriptにはis:inlineを書く

ツールページには構造化データを置いている。

<script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />

is:inlineを外すとastro checkのヒントが1つ増える。

astro(4000): This script will be treated as if it has the `is:inline` directive
because it contains an attribute. Therefore, features that require processing
(e.g. using TypeScript or npm packages in the script) are unavailable.

Add the `is:inline` directive explicitly to silence this hint.

Astroの<script>は既定でビルドの処理対象になる。属性が付いていると処理を諦めて素通しにするので、type="application/ld+json"の中身がesbuildに渡ることはない。

処理される側がどうなるかは出力を見れば分かる。この2行を並べたページをビルドしてみる。

<script>console.log('processed')</script>
<script type="application/ld+json" set:html={json} />

出たdistのHTMLがこれ。

<script type="module">console.log(`processed`);</script><script type="application/ld+json">{"a":1}</script>

属性なしのほうはesbuildを通っていて、シングルクォートがバッククォートに書き換わっている。typeが付いているほうはJSON.stringifyの結果がそのまま出ている。

is:inlineの有無で出力が変わるかも比べた。付けた版と外した版でdistの当該行をdiffしたら差分ゼロ。挙動は同じで、ヒントが出るかどうかだけが違う。

なのでis:inlineは、「これは処理されないままで正しい」という意思表示として書く。JSONを吐きたいだけの<script>にTypeScriptやnpmパッケージの持ち込みは要らないので、諦めてもらって構わない。

ちなみに<script set:html={json} />と属性を何も付けない形でも、同じヒントが出る。set:html自体が「属性が付いている」に数えられている。

まとめ

3つとも、動いてはいるけれど何も言っていない、たぐいの細部だった。

  • 同着のときの並びは、比較関数が黙るとファイルシステムが決める
  • キャストは型の要求を満たす道具であって、要求が変わったら外していい
  • ヒントを消すディレクティブは、消すためというより、そのつもりで書いたと残すために書く

移行はだいたい動く。動いたあとに残るのはこういうやつだと思う。

Footnotes

  1. ext4のdir_indexのようにハッシュ順で返す実装もあるし、fs.readdirSyncは仕様として順序を約束していない。 ↩

← 一覧へ戻る