ビルド工程を持たない静的サイトを運用していると、同じ数字を何か所にも書く ことになる。一覧ページの「全95点」、絞り込みボタンの「QR・バーコード 18」、カテゴリ見出しの「19点」、サイドナビの折りたたみに付く件数。どれも人間には親切だが、1件追加した瞬間に全部が嘘になる。
自分が運用しているハシトシステム は、まさにこの形のサイトだ。public/ がそのまま本番で、テンプレートエンジンもビルドも無い。今日ツールを2件追加したところ、テストが10本落ちた 。落ちた内容がそのまま「静的サイトで件数を持つときの落とし穴の一覧」になっていたので、そこを起点に書く。
実際に落ちたエラー 追加したのは public/tools/utmqr/ と public/tools/lorem-html/ の2つ(と、その英語版)。node --test test/ の出力から抜き出すと、こうなった。
1 2 3 4 5 6 7 8 9 not ok 555 - public/index.html: カテゴリごとの件数が台帳と一致する error : 'qr: リンク 20 本(台帳 18 )'not ok 556 - public/index.html: 全ツールがどれか1 つのカテゴリに1 回だけ出ている error : 97 !== 95 not ok 725 - public/tools/index.html: 絞り込みボタンが台帳のカテゴリと1 対1 で、件数も一致するnot ok 726 - public/tools/index.html: カテゴリ見出しが絞り込みボタンと1 対1 で、説明とハブリンクを持つ error : 'qr の見出し件数が実体と違う'not ok 736 - public/index.html: 「一覧を見る」の件数が台帳と一致する error : 95 !== 97
数字を書いた場所は、日英合わせて9か所あった。トップの <li> 一覧、サイドナビの <summary>、一覧ページの絞り込みボタン、カテゴリ見出し、ItemList の JSON-LD、そして「無料ツール一覧を見る(全95点)」のリンク文言。
面白いのは 555 の内容 だ。「qr: リンク 20本(台帳 18)」と出ている。追加したのは QR 系ツールが1つ(utmqr)とテキスト系が1つ(lorem-html)なのに、qr カテゴリが2件増えている。理由は単純で、リンクを挿入するスクリプトが両方とも既存の QR ツールの直後に入れた からだ。人間が手で貼っても同じことが起きる。「近い場所にコピペする」のがいちばん楽なので、分類の違うものが同じ節に混ざる。
これは目視では見つからない。一覧に並んでいれば、それらしく見えてしまう。
正を1か所に置く 対処は、数え上げの正(source of truth)を1つ決めることに尽きる。ここでは data/tools.json を台帳とした。中身はこういう形をしている。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "categories" : [ { "slug" : "qr" , "ja" : "QR・バーコード" , "en" : "QR & Barcode" , "descJa" : "…" , "descEn" : "…" } ], "tools" : [ { "slug" : "utmqr" , "category" : "qr" , "added" : "2026-08-08" , "ja" : { "name" : "UTMパラメータ付きQRコード生成" , "desc" : "…" , "icon" : "UTM" }, "en" : { "name" : "UTM QR Code Generator" , "desc" : "…" , "icon" : "UTM" } } ] }
重要なのは、この JSON がページを生成しない ことだ。public/ は手書きの HTML のままで、台帳は「あるべき姿」を書いた紙にすぎない。生成しないなら意味がないように見えるが、そうではない。生成の代わりに検査 する。
生成に踏み切らなかったのは、このサイトが「public/ がそのまま本番」という単純さで成り立っているからだ。ビルドを入れると、デプロイ手順・キャッシュ・生成物のコミット可否といった判断が一気に増える。壊れを検出できれば十分で、直すのは手でよい 、という線を選んだ。
台帳と画面を突き合わせるテスト テストは node:test だけで書ける。依存は要らない。まず台帳自身の健全性を見る。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 "use strict" ;const { test } = require ("node:test" );const assert = require ("node:assert/strict" );const fs = require ("node:fs" );const path = require ("node:path" );const ROOT = path.join(__dirname, ".." );const read = (rel ) => fs.readFileSync(path.join(ROOT, rel), "utf8" );const LEDGER = JSON .parse(read("data/tools.json" ));const CATS = LEDGER.categories;const TOOLS = LEDGER.tools;const countByCat = new Map ();for (const t of TOOLS) countByCat.set(t.category, (countByCat.get(t.category) || 0 ) + 1 ); test("台帳: 全ツールが有効なカテゴリをちょうど1つ持つ" , () => { const known = new Set (CATS.map((c ) => c.slug)); const bad = TOOLS.filter((t ) => typeof t.category !== "string" || !known.has(t.category)) .map((t ) => `${t.slug} =${t.category} ` ); assert.deepEqual(bad, [], `カテゴリが不正なツール: ${bad.join(", " )} ` ); assert.equal( TOOLS.length, [...countByCat.values()].reduce((a, b ) => a + b, 0 ), "カテゴリ別の合計が台帳の件数と合わない" ); });
assert.deepEqual(bad, []) にしているのは意図的だ。assert.equal(bad.length, 0) だと「0 !== 3」としか出ないが、空配列との比較にするとどのツールが悪いのか がそのままエラーに出る。テストの失敗メッセージは、直す人がまず読む文章なので、ここは手を抜かないほうがいい。
次に画面を見る。HTML から数字を取り出すのに、パーサを持ち込む必要はない。抜きたい箇所を印で囲っておけば、正規表現で足りる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const sideTools = (src ) => src.match(/<!-- GEN:side-tools START -->([\s\S]*?)<!-- GEN:side-tools END -->/ )[1 ];const categories = (block ) => [...block.matchAll( /<details class="side-cat" data-cat="([^"]+)"[^>]*><summary>([^<]*)<span class="side-n">(\d+)<\/span><\/summary>([\s\S]*?)<\/details>/g )].map((m ) => ({ slug : m[1 ], label : m[2 ], shown : Number (m[3 ]), body : m[4 ] })); test("public/index.html: カテゴリごとの件数が台帳と一致する" , () => { for (const c of categories(sideTools(read("public/index.html" )))) { const want = countByCat.get(c.slug); const items = (c.body.match(/<li[ >]/g ) || []).length; assert.equal(c.shown, want, `${c.slug} : 見出しの件数 ${c.shown} (台帳 ${want} )` ); assert.equal(items, want, `${c.slug} : リンク ${items} 本(台帳 ${want} )` ); } });
ここで見出しの数字と実際のリンク本数を別々に見ている のが要点だ。片方だけだと、今日の事故は捕まらない。見出しを 20 に手で直せば「見出し=リンク数」は一致してしまうが、台帳とは合わない。逆に台帳とだけ比べると、見出しを直して中身を直し忘れた状態を見逃す。3つの数(台帳・見出し・実体)が全部そろって初めて正しい。
「全95点」のようなリンク文言も、印で囲えば同じやり方で見られる。
1 <a href ="/tools/" > 無料ツール一覧を見る(全97点)→</a >
1 2 3 4 5 6 7 const TOOLS_COUNT = /<!-- GEN:tools-count START -->[\s\S]*?(全(\d+)点)[\s\S]*?<!-- GEN:tools-count END -->/ ; test("public/index.html: 「一覧を見る」の件数が台帳と一致する" , () => { const m = read("public/index.html" ).match(TOOLS_COUNT); assert.ok(m, "ツール件数のリンクが見つからない" ); assert.equal(Number (m[1 ]), TOOLS.length); });
HTMLコメントの印を使う理由 class やカスタム属性ではなく HTML コメントで囲っているのは、デザインを変えても壊れないため だ。クラス名は見た目の都合でいつでも変わる。変わった瞬間にテストが対象を見失い、しかも「0件を検査して緑」という最悪の壊れ方をする。
これを避けるには、対象が見つからないことを失敗にしておくのも要る。上の assert.ok(m, "…が見つからない") がそれだ。if (m) { ... } と書いてしまうと、印が消えた日からそのテストは何も検査しなくなる。
同じ理由で、matchAll の結果が0件のときも失敗にしておくとよい。
1 2 3 4 5 6 test("台帳のカテゴリが順番どおり全部出ている" , () => { const cats = categories(sideTools(read("public/index.html" ))); assert.deepEqual(cats.map((c ) => c.slug), CATS.map((c ) => c.slug)); assert.deepEqual(cats.map((c ) => c.label), CATS.map((c ) => c.ja)); });
ラベルまで比較しているのは、画面側に分類名を書き写させない ためだ。書き写しを許すと、台帳で「変換・エンコード」を「エンコード」に改名したときに画面だけ旧名が残る。実際このサイトでは以前、分類が cats と categoryV2 の2系統あり、一覧ページに「変換・エンコード」と「エンコード・文字コード」という違いを説明できない行が2つ並んでいた 。統合したあとにこのテストを置いたのは、その状態に戻らないようにするためだ。
実際に直した順序 今日の10本は、次の順で消えた。
data/tools.json に2件追加(utmqr = qr、lorem-html = text)
絞り込みボタン・カテゴリ見出し・サイドナビの件数を台帳から再計算して置換
「全95点」を 97 に更新(日英とも)
lorem-html のリンクとカードを、qr の節から text の節へ移動
4 が最後に残ったのが象徴的だ。件数の数字は直しやすく、置き場所の誤りは気づきにくい 。数字だけを機械が見ていると「見出しは合っているのに中身が違う」状態を通してしまうので、リンク本数まで数えるテストが要る。
置換自体は Python でも Node でも一気にやれる。カテゴリ件数は台帳から作れるので、手で数えないこと。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const fs = require ("node:fs" );const LEDGER = JSON .parse(fs.readFileSync("data/tools.json" , "utf8" ));const count = new Map ();for (const t of LEDGER.tools) count.set(t.category, (count.get(t.category) || 0 ) + 1 );for (const f of ["public/index.html" , "public/en/index.html" ]) { let s = fs.readFileSync(f, "utf8" ); for (const [cat, n] of count) { s = s.replace( new RegExp (`(data-cat="${cat} "[^>]*><summary>[^<]*<span class="side-n">)\\d+(</span>)` ), (_, a, b ) => a + n + b ); } fs.writeFileSync(f, s); }
この形が向く場面・向かない場面 向くのは、ページ数が数十から数百で、生成を入れるほどではないが手書きが限界に来ている サイトだ。台帳とテストを足すコストは1日で済み、以後は「追加したのに一覧に出ていない」「件数が古い」という種類の事故が全部ビルド前に出る。
向かないのは、ページの構造そのものが毎回違うサイトだ。正規表現で抜ける前提が崩れるので、その場合は素直にテンプレートエンジンを入れたほうがいい。
判断の目安として、同じ事実が3か所以上に書いてあるなら台帳を作る価値がある と考えている。2か所なら片方を直し忘れても気づけるが、3か所を超えると人間の注意では追えなくなる。今日の件数は9か所だった。
なお、このテストは日次の自動更新ランからも走っている。反映前ゲート(preflight)が node --test test/ を実行し、1本でも落ちれば firebase deploy に進まない。壊れた一覧が本番に出る前に止まる ので、深夜に無人で記事やツールを足しても、少なくとも数え上げの整合性は保たれる。手で書き換えるサイトほど、この種の機械的な見張りが効く。