sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+86 -14
View File
@@ -2,12 +2,15 @@
// 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 } from 'node:path';
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));
const HELP_DIR = resolve(__dirname, '../ui/src/content/help');
// 検証するフォルダ。引数で差し替えられるのはテスト用(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;
@@ -20,30 +23,41 @@ try {
}
const CATEGORIES = ['basic', 'advanced', 'admin'];
// KEEP IN SYNC with ui/src/lib/help.ts slugify()
function slugify(text) {
const s = text.replace(/<[^>]*>/g, '').trim().toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '');
return s || 'section';
// 見出し → 飛び先の名前(アンカー)の規則は 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 { frontmatter: '', body: n };
return { frontmatter: m[1], body: n.slice(m[0].length) };
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 { frontmatter, body } = splitFrontmatter(raw);
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; }
@@ -76,10 +90,68 @@ for (const file of files) {
if (count > 1) errors.push(`${file}: ${count} headings collide on slug '${slug}' (anchor links ambiguous)`);
}
// Relative markdown links that point at local files must resolve.
for (const m of body.matchAll(/\]\((\.{1,2}\/[^)]+)\)/g)) {
const target = resolve(HELP_DIR, m[1].split('#')[0]);
if (!existsSync(target)) errors.push(`${file}: broken relative link ${m[1]}`);
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}`);
}
}
}