diff --git a/docs/package.json b/docs/package.json index 0f12df4d4..3ba293609 100644 --- a/docs/package.json +++ b/docs/package.json @@ -7,7 +7,8 @@ "dev": "vitepress dev .", "build": "vitepress build .", "preview": "vitepress preview .", - "check:locales": "node scripts/check-locales.mjs" + "check:locales": "node scripts/check-locales.mjs", + "test": "node --test" }, "devDependencies": { "vitepress": "^1.6.4" diff --git a/docs/scripts/check-locales.mjs b/docs/scripts/check-locales.mjs index 9f8721e7e..6d8c37f1b 100644 --- a/docs/scripts/check-locales.mjs +++ b/docs/scripts/check-locales.mjs @@ -13,9 +13,14 @@ function markdownFiles(directory) { .sort() } -const englishFiles = markdownFiles(englishRoot) -const missing = [] -const invalid = [] +/** + * Route the Chinese notice must link, built from a path relative to `docs/spec`. + * Routed links are POSIX, but `path.relative` separates with backslashes on + * Windows, so normalize the separators before assembling the route. + */ +export function noticeRoute(relativePath) { + return `/spec/${relativePath.split(path.sep).join('/').replace(/\.md$/, '')}` +} function tableShape(source) { return source.split('\n') @@ -24,34 +29,50 @@ function tableShape(source) { .map((line) => [...line].filter((character) => character === '|').length) } -for (const relativePath of englishFiles) { - const translatedPath = path.join(chineseRoot, relativePath) - if (!fs.existsSync(translatedPath)) { - missing.push(relativePath) - continue +export function verifyLocalePairs() { + const englishFiles = markdownFiles(englishRoot) + const missing = [] + const invalid = [] + + for (const relativePath of englishFiles) { + const translatedPath = path.join(chineseRoot, relativePath) + if (!fs.existsSync(translatedPath)) { + missing.push(relativePath) + continue + } + + const source = fs.readFileSync(translatedPath, 'utf8') + const englishSource = fs.readFileSync(path.join(englishRoot, relativePath), 'utf8') + const englishRoute = noticeRoute(relativePath) + const tableStructureMatches = JSON.stringify(tableShape(source)) === JSON.stringify(tableShape(englishSource)) + const fenceStructureMatches = (source.match(/^```/gm) ?? []).length === (englishSource.match(/^```/gm) ?? []).length + if ( + !/^#\s+\S+/m.test(source) + || !/[\u3400-\u9fff]/.test(source) + || !source.includes(`[英文源规格](${englishRoute})`) + || source.includes('PIHOLDTOKEN') + || !tableStructureMatches + || !fenceStructureMatches + ) { + invalid.push(relativePath) + } } - const source = fs.readFileSync(translatedPath, 'utf8') - const englishSource = fs.readFileSync(path.join(englishRoot, relativePath), 'utf8') - const englishRoute = `/spec/${relativePath.replace(/\.md$/, '')}` - const tableStructureMatches = JSON.stringify(tableShape(source)) === JSON.stringify(tableShape(englishSource)) - const fenceStructureMatches = (source.match(/^```/gm) ?? []).length === (englishSource.match(/^```/gm) ?? []).length - if ( - !/^#\s+\S+/m.test(source) - || !/[\u3400-\u9fff]/.test(source) - || !source.includes(`[英文源规格](${englishRoute})`) - || source.includes('PIHOLDTOKEN') - || !tableStructureMatches - || !fenceStructureMatches - ) { - invalid.push(relativePath) + return { englishFiles, missing, invalid } +} + +function main() { + const { englishFiles, missing, invalid } = verifyLocalePairs() + + if (missing.length || invalid.length) { + if (missing.length) console.error(`Missing Chinese specifications:\n${missing.join('\n')}`) + if (invalid.length) console.error(`Invalid Chinese source notices:\n${invalid.join('\n')}`) + process.exitCode = 1 + } else { + console.log(`Verified ${englishFiles.length} English/Chinese specification pairs.`) } } -if (missing.length || invalid.length) { - if (missing.length) console.error(`Missing Chinese specifications:\n${missing.join('\n')}`) - if (invalid.length) console.error(`Invalid Chinese source notices:\n${invalid.join('\n')}`) - process.exitCode = 1 -} else { - console.log(`Verified ${englishFiles.length} English/Chinese specification pairs.`) +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main() } diff --git a/docs/scripts/check-locales.test.mjs b/docs/scripts/check-locales.test.mjs new file mode 100644 index 000000000..12e9ca165 --- /dev/null +++ b/docs/scripts/check-locales.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { test } from 'node:test' +import { noticeRoute, verifyLocalePairs } from './check-locales.mjs' + +test('notice routes keep POSIX separators on every platform', () => { + // `markdownFiles` builds its paths with `path.relative`, which separates with + // backslashes on Windows. The notice is a routed link, so a backslash there + // makes every Chinese mirror look like it is missing its source notice. + const relative = ['01-product', '00-overview.md'].join(path.sep) + + assert.equal(noticeRoute(relative), '/spec/01-product/00-overview') + assert.equal(noticeRoute('03-runtime/01-ipc-protocol.md'), '/spec/03-runtime/01-ipc-protocol') + assert.ok(!noticeRoute(relative).includes('\\')) +}) + +test('every English specification has a valid Chinese mirror', () => { + const { englishFiles, missing, invalid } = verifyLocalePairs() + + assert.ok(englishFiles.length > 0, 'expected to discover English specifications') + assert.deepEqual(missing, [], 'Chinese specifications missing for the listed English pages') + assert.deepEqual(invalid, [], 'Chinese pages whose source notice or structure is invalid') +})