Files
maestro/scripts/validate-help-docs.mjs
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

164 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// scripts/validate-help-docs.mjs
// Validates ui/src/content/help/*.md frontmatter + structural invariants.
// Exits non-zero on any error. Run before `vite build`.
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, dirname, resolve, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = dirname(fileURLToPath(import.meta.url));
// 検証するフォルダ。引数で差し替えられるのはテスト用(scripts/validate-help-docs.test.ts)。
const HELP_DIR = process.argv[2]
? resolve(process.cwd(), process.argv[2])
: resolve(__dirname, '../ui/src/content/help');
// `yaml` lives in ui/node_modules (the build runs `node ../scripts/...` from ui/).
// Resolve it relative to ui/ so this script works regardless of cwd.
let parseYaml;
try {
const require = createRequire(pathToFileURL(resolve(__dirname, '../ui/package.json')));
({ parse: parseYaml } = await import(pathToFileURL(require.resolve('yaml')).href));
} catch {
console.error("help-docs validation: cannot load 'yaml' from ui/node_modules — run `npm --prefix ui install`");
process.exit(1);
}
const CATEGORIES = ['basic', 'advanced', 'admin'];
// 見出し → 飛び先の名前(アンカー)の規則は ui/src/lib/help-slug.ts が唯一の出どころ。
// 画面が実際に付ける id と同じものをここでも使うため、規則を写さず読み込む (#1274)。
// Node は 22.18 以降 .ts をそのまま読める(型を剥がして実行する)。
let slugify, collectHeadingIds;
try {
({ slugify, collectHeadingIds } = await import(
pathToFileURL(resolve(__dirname, '../ui/src/lib/help-slug.ts')).href
));
} catch (e) {
console.error(
'help-docs validation: cannot load ui/src/lib/help-slug.ts — needs Node >= 22.18 ' +
'(TypeScript type stripping) and `npm --prefix ui install`\n ' + (e?.message ?? e),
);
process.exit(1);
}
function splitFrontmatter(raw) {
const n = raw.replace(/^/, '').replace(/\r\n/g, '\n');
const m = /^---\n([\s\S]*?)\n---\n?/.exec(n);
if (!m) return { normalized: n, frontmatter: '', body: n };
return { normalized: n, frontmatter: m[1], body: n.slice(m[0].length) };
}
const errors = [];
const ids = new Map();
const orders = new Map();
/** file -> { normalized, body }。リンクの検証で使う。 */
const docs = new Map();
const files = readdirSync(HELP_DIR).filter((f) => f.endsWith('.md')).sort();
if (files.length === 0) errors.push('no .md files found in help dir');
for (const file of files) {
const raw = readFileSync(join(HELP_DIR, file), 'utf8');
const { normalized, frontmatter, body } = splitFrontmatter(raw);
if (!frontmatter) { errors.push(`${file}: missing frontmatter block`); continue; }
let data;
try { data = parseYaml(frontmatter); } catch (e) { errors.push(`${file}: YAML error ${e.message}`); continue; }
if (typeof data?.id !== 'string' || !data.id.trim()) errors.push(`${file}: 'id' required`);
if (typeof data?.title !== 'string' || !data.title.trim()) errors.push(`${file}: 'title' required`);
if (!CATEGORIES.includes(data?.category)) errors.push(`${file}: 'category' must be ${CATEGORIES.join('|')}`);
if (typeof data?.order !== 'number' || !Number.isFinite(data.order)) errors.push(`${file}: 'order' must be a number`);
if (data?.keywords != null && (!Array.isArray(data.keywords) || data.keywords.some((k) => typeof k !== 'string')))
errors.push(`${file}: 'keywords' must be a string array`);
if (typeof data?.id === 'string') {
if (ids.has(data.id)) errors.push(`${file}: duplicate id '${data.id}' (also ${ids.get(data.id)})`);
else ids.set(data.id, file);
}
if (typeof data?.order === 'number') {
if (orders.has(data.order)) errors.push(`${file}: duplicate order ${data.order} (also ${orders.get(data.order)})`);
else orders.set(data.order, file);
}
// Duplicate heading slugs within a file (anchor links would be ambiguous).
const slugCounts = new Map();
for (const line of body.split('\n')) {
const h = /^(#{2,3})\s+(.*)$/.exec(line);
if (!h) continue;
const slug = slugify(h[2]);
slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1);
}
for (const [slug, count] of slugCounts) {
if (count > 1) errors.push(`${file}: ${count} headings collide on slug '${slug}' (anchor links ambiguous)`);
}
docs.set(file, { normalized, body });
}
// --- Links -----------------------------------------------------------------
// ファイルの存在だけでなく、`#` から後ろ(飛び先の見出し)も突き合わせる。
// 見出しの文言を書き換えたのにリンク側が追いついていない、を落とすため (#1274)。
// 別のページ宛て(`](./xx.md#…)`)と同じページ内(`](#…)`)の両方を見る。
// 片方だけ見張ると「見出しリンクは見張られている」という誤った前提ができる。
/** その本文を描画したときに実際に付く見出し id。ファイル単位で 1 度だけ数える。 */
const headingIdCache = new Map();
function headingIdsOf(file) {
let ids = headingIdCache.get(file);
if (!ids) {
ids = new Set(collectHeadingIds(docs.get(file).body));
headingIdCache.set(file, ids);
}
return ids;
}
/** 文字位置 → 1 始まりの行番号(元ファイルの行と一致させるため normalized 全体で数える)。 */
function lineAt(text, index) {
let line = 1;
for (let i = 0; i < index; i++) if (text[i] === '\n') line++;
return line;
}
function decodeFragment(fragment) {
try { return decodeURIComponent(fragment); } catch { return fragment; }
}
for (const [file, { normalized }] of docs) {
// href の切り出しは以前と同じ(`)` まで)。狭めると、これまで拾えていた
// 書き損じ(空白入りのパスなど)を見落とす。
for (const m of normalized.matchAll(/\]\((\.{1,2}\/[^)]+)\)/g)) {
const href = m[1];
const line = lineAt(normalized, m.index);
const where = `${file}:${line}: ${m[0]}`;
const hashAt = href.indexOf('#');
const path = hashAt === -1 ? href : href.slice(0, hashAt);
const fragment = hashAt === -1 ? '' : decodeFragment(href.slice(hashAt + 1));
// 宛先のファイルが在ること。
const target = resolve(HELP_DIR, path);
if (!existsSync(target)) { errors.push(`${where} — target file not found`); continue; }
if (!fragment) continue;
const targetFile = relative(HELP_DIR, target);
if (!docs.has(targetFile)) continue; // ヘルプ以外のファイル(見出しを数えない)
if (!headingIdsOf(targetFile).has(fragment)) {
errors.push(`${where} — no heading '#${fragment}' in ${targetFile}`);
}
}
// 同じページ内のリンク(`](#見出し)`)。飛び先は自分自身の見出し。
for (const m of normalized.matchAll(/\]\((#[^)]+)\)/g)) {
const fragment = decodeFragment(m[1].slice(1));
if (!fragment) continue;
const line = lineAt(normalized, m.index);
if (!headingIdsOf(file).has(fragment)) {
errors.push(`${file}:${line}: ${m[0]} — no heading '#${fragment}' in ${file}`);
}
}
}
if (errors.length) {
console.error('help-docs validation FAILED:');
for (const e of errors) console.error(' - ' + e);
process.exit(1);
}
console.log(`help-docs validation OK (${files.length} files)`);