Files

195 lines
7.2 KiB
JavaScript

#!/usr/bin/env node
// scripts/check-doc-aliases.js
// Checks that every alias a page declares in frontmatter is also carried by that page's
// entry in DocBundleLoader.kt, the in-app docs index.
// Exit 0 = every authored alias is registered, Exit 1 = drift found.
//
// Contract: the loader MUST be a superset of the frontmatter, not identical to it.
// Only the loader's list reaches the running app — DefaultDocBundleLoader.stripFrontmatter()
// discards the frontmatter before the markdown is rendered, and KeywordSearchEngine scores
// against DocPage.aliases, which comes from the loader. sync-android-docs.js strips aliases
// for Docusaurus too, so an alias that exists only in frontmatter is a search term the author
// wrote and no consumer ever sees — the drift this check exists to catch. The reverse is
// harmless: an extra loader alias is extra search vocabulary for a page that already owns it,
// and several are deliberate (signal-meter keeps "signal-quality"/"signal-strength", translate
// keeps "language"/"i18n"/"contribute"). Demanding equality would make those permanently red.
//
// Usage: node scripts/check-doc-aliases.js [repo-root]
"use strict";
const fs = require("fs");
const path = require("path");
const { parseFrontmatter, parseListField, forEachDocPage } = require("./lib/frontmatter");
const REPO_ROOT = path.resolve(process.argv[2] || ".");
const DOCS_DIR = path.join(REPO_ROOT, "docs", "en");
const LOADER_REL = path.join(
"feature", "docs", "src", "commonMain", "kotlin", "org", "meshtastic", "feature", "docs", "data",
"DocBundleLoader.kt",
);
const LOADER_PATH = path.join(REPO_ROOT, LOADER_REL);
// A page entry in the loader is anchored on its resourcePath literal. User entries
// (UserPageDef) carry one list — the aliases; their keywords come from a string resource.
// Developer entries (KeywordIndexEntry) carry two — keywords first, then aliases.
const ALIAS_LIST_INDEX = { user: 0, developer: 1 };
const EXPECTED_LISTS = { user: 1, developer: 2 };
/** Extract the string literals of each listOf(...)/emptyList() group in a slice of Kotlin. */
function parseListGroups(source) {
const groups = [];
const openRe = /\b(listOf|emptyList)\s*\(/g;
let match;
while ((match = openRe.exec(source)) !== null) {
if (match[1] === "emptyList") {
groups.push([]);
continue;
}
// Walk forward from the opening paren, string-aware, to find its partner.
let depth = 1;
let i = openRe.lastIndex;
let inString = false;
while (i < source.length && depth > 0) {
const ch = source[i];
if (inString) {
if (ch === "\\") i++;
else if (ch === '"') inString = false;
} else if (ch === '"') {
inString = true;
} else if (ch === "(") {
depth++;
} else if (ch === ")") {
depth--;
}
i++;
}
const inner = source.slice(openRe.lastIndex, i - 1);
groups.push([...inner.matchAll(/"([^"\\]*)"/g)].map(m => m[1]));
openRe.lastIndex = i;
}
return groups;
}
/** Slice out the constructor call that encloses `index`, parens balanced. */
function enclosingEntry(source, index) {
const ctorRe = /\b(UserPageDef|KeywordIndexEntry)\s*\(/g;
let open = -1;
let match;
while ((match = ctorRe.exec(source)) !== null && match.index < index) {
open = ctorRe.lastIndex;
}
if (open < 0) return null;
let depth = 1;
let i = open;
let inString = false;
while (i < source.length && depth > 0) {
const ch = source[i];
if (inString) {
if (ch === "\\") i++;
else if (ch === '"') inString = false;
} else if (ch === '"') {
inString = true;
} else if (ch === "(") {
depth++;
} else if (ch === ")") {
depth--;
}
i++;
}
return i > index ? source.slice(open, i - 1) : null;
}
/** Map "user/nodes" -> { section, slug, lists } for every page registered in DocBundleLoader.kt. */
function parseLoaderEntries(source) {
const anchorRe = /"en\/(user|developer)\/([a-z0-9-]+)\.html"/g;
const entries = new Map();
for (const anchor of source.matchAll(anchorRe)) {
const [, section, slug] = anchor;
const body = enclosingEntry(source, anchor.index);
entries.set(`${section}/${slug}`, { section, slug, lists: body ? parseListGroups(body) : [] });
}
return entries;
}
if (!fs.existsSync(LOADER_PATH)) {
console.log(`ERROR: ${LOADER_REL} not found under ${REPO_ROOT}.`);
process.exit(1);
}
const loaderEntries = parseLoaderEntries(fs.readFileSync(LOADER_PATH, "utf-8"));
console.log(`Checking frontmatter aliases against ${loaderEntries.size} DocBundleLoader entries...`);
console.log("");
let errors = 0;
let extras = 0;
let checked = 0;
forEachDocPage(DOCS_DIR, (filePath, slug, section) => {
const relPath = path.relative(REPO_ROOT, filePath);
const { raw } = parseFrontmatter(fs.readFileSync(filePath, "utf-8"));
const declared = parseListField(raw, "aliases");
const entry = loaderEntries.get(`${section}/${slug}`);
if (!entry) {
console.log(
`::error file=${relPath}::no DocBundleLoader.kt entry for '${section}/${slug}', ` +
`so its ${declared.length} frontmatter alias(es) are not searchable in the app.`,
);
errors++;
return;
}
// Guard the shape rather than silently comparing the wrong list: a UserPageDef that grew a
// keyword list, or a KeywordIndexEntry that lost one, would otherwise pass while checking
// keywords against aliases.
if (entry.lists.length !== EXPECTED_LISTS[section]) {
console.log(
`::error file=${LOADER_REL}::entry '${section}/${slug}' has ${entry.lists.length} ` +
`list(s); expected ${EXPECTED_LISTS[section]}. Update check-doc-aliases.js if the ` +
`loader's entry shape changed.`,
);
errors++;
return;
}
checked++;
const registered = new Set(entry.lists[ALIAS_LIST_INDEX[section]].map(a => a.toLowerCase()));
const missing = declared.filter(a => !registered.has(a.toLowerCase()));
if (missing.length > 0) {
console.log(
`::error file=${relPath}::aliases ${missing.map(a => `'${a}'`).join(", ")} are declared ` +
`in frontmatter but missing from the DocBundleLoader.kt entry for '${section}/${slug}', ` +
`so in-app search will not match them.`,
);
errors++;
}
const declaredSet = new Set(declared.map(a => a.toLowerCase()));
const loaderOnly = [...registered].filter(a => !declaredSet.has(a));
if (loaderOnly.length > 0) {
console.log(` note: ${section}/${slug} — loader-only alias(es): ${loaderOnly.join(", ")}`);
extras++;
}
});
console.log("");
console.log(`Compared ${checked} page(s); ${extras} carry loader-only aliases (allowed).`);
if (errors > 0) {
console.log(`\nFAILED: ${errors} page(s) whose frontmatter aliases are not registered in DocBundleLoader.kt.`);
process.exit(1);
} else {
console.log("PASSED: every frontmatter alias is registered in DocBundleLoader.kt.");
process.exit(0);
}