静的HTMLで「全95点」が嘘になる前に、台帳とテストで止める

ビルド工程を持たない静的サイトを運用していると、同じ数字を何か所にも書くことになる。一覧ページの「全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: 絞り込みボタンが台帳のカテゴリと11で、件数も一致する
not ok 726 - public/tools/index.html: カテゴリ見出しが絞り込みボタンと11で、説明とハブリンクを持つ
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
// <!-- GEN:side-tools START --> … <!-- GEN:side-tools END --> を取り出す
const sideTools = (src) =>
src.match(/<!-- GEN:side-tools START -->([\s\S]*?)<!-- GEN:side-tools END -->/)[1];

// <details data-cat="qr" ...><summary>QR・バーコード<span class="side-n">19</span></summary> … </details>
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
<!-- GEN:tools-count START --><a href="/tools/">無料ツール一覧を見る(全97点)→</a><!-- GEN:tools-count END -->
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));
});

ラベルまで比較しているのは、画面側に分類名を書き写させないためだ。書き写しを許すと、台帳で「変換・エンコード」を「エンコード」に改名したときに画面だけ旧名が残る。実際このサイトでは以前、分類が catscategoryV2 の2系統あり、一覧ページに「変換・エンコード」と「エンコード・文字コード」という違いを説明できない行が2つ並んでいた。統合したあとにこのテストを置いたのは、その状態に戻らないようにするためだ。

実際に直した順序

今日の10本は、次の順で消えた。

  1. data/tools.json に2件追加(utmqr = qr、lorem-html = text)
  2. 絞り込みボタン・カテゴリ見出し・サイドナビの件数を台帳から再計算して置換
  3. 「全95点」を 97 に更新(日英とも)
  4. 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 に進まない。壊れた一覧が本番に出る前に止まるので、深夜に無人で記事やツールを足しても、少なくとも数え上げの整合性は保たれる。手で書き換えるサイトほど、この種の機械的な見張りが効く。


静的HTMLで「全95点」が嘘になる前に、台帳とテストで止める
https://blog.hashito.biz/2026/08/08/static-site-hardcoded-counts-ledger-test/
著者
hashito
作成日
2026年8月8日
著作権