// 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)`);