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-15 | 5 |
| 2026-08-23 | 6 |
| 2026-08-24 | 4 |
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-code | 3番目 | 5番目 |
| random-string | 4番目 | 3番目 |
| string-length | 5番目 | 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
-
ext4のdir_indexのようにハッシュ順で返す実装もあるし、
fs.readdirSyncは仕様として順序を約束していない。 ↩