Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions scripts/__tests__/frontmatter-check.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { frontmatterError } from '../check-mdx-parse.mjs';

test('frontmatterError flags nested-quote frontmatter', () => {
const content = '---\ntitle: "ok"\ndescription: "带有、"显示所有方案"链接。"\n---\n\nBody\n';
const err = frontmatterError(content);
assert.ok(err, 'expected an error object');
assert.equal(typeof err.message, 'string');
});

test('frontmatterError returns null for valid frontmatter', () => {
const content = '---\ntitle: "ok"\ndescription: "a clean one"\n---\n\nBody\n';
assert.equal(frontmatterError(content), null);
});

test('frontmatterError returns null when there is no frontmatter', () => {
assert.equal(frontmatterError('Just body text, no frontmatter.\n'), null);
});
114 changes: 114 additions & 0 deletions scripts/__tests__/nested-quote.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
fixFrontmatterQuotes,
fixTagAttrQuotes,
fixNestedQuotesInBody,
fixNestedQuotes,
parsesClean,
} from '../lint-mdx.mjs';

test('fixFrontmatterQuotes converts inner double-quotes to single quotes', () => {
const fm = 'title: "ok"\ndescription: "构建带有、"显示所有方案"链接的付费墙。"';
const { result, changed } = fixFrontmatterQuotes(fm);
assert.equal(changed, true);
assert.equal(
result,
'title: "ok"\ndescription: "构建带有、\'显示所有方案\'链接的付费墙。"',
);
});

test('fixFrontmatterQuotes leaves clean frontmatter untouched', () => {
const fm = 'title: "ok"\ndescription: "a clean description"';
const { result, changed } = fixFrontmatterQuotes(fm);
assert.equal(changed, false);
assert.equal(result, fm);
});

test('fixFrontmatterQuotes preserves escaped quotes', () => {
const fm = 'description: "she said \\"hi\\""';
const { result, changed } = fixFrontmatterQuotes(fm);
assert.equal(changed, false);
assert.equal(result, fm);
});

test('fixTagAttrQuotes fixes inner quotes in the last attribute', () => {
const tag = '<ZoomImage id="a.webp" width="500px" alt="配置了"打开 URL"操作的链接" />';
const { result, changed } = fixTagAttrQuotes(tag);
assert.equal(changed, true);
assert.equal(
result,
'<ZoomImage id="a.webp" width="500px" alt="配置了\'打开 URL\'操作的链接" />',
);
});

test('fixTagAttrQuotes leaves a clean tag untouched', () => {
const tag = '<ZoomImage id="a.webp" width="500px" alt="clean alt text" />';
const { result, changed } = fixTagAttrQuotes(tag);
assert.equal(changed, false);
assert.equal(result, tag);
});

test('fixTagAttrQuotes does not flag a valueless/boolean attribute', () => {
// `default` is a boolean attribute — the closing quote of `label` is
// followed by ` default>`, which must be recognized as a boundary.
const tag = '<TabItem value="builder" label="Paywall Builder" default>';
const { result, changed } = fixTagAttrQuotes(tag);
assert.equal(changed, false);
assert.equal(result, tag);
});

test('fixTagAttrQuotes leaves a clean tag with trailing boolean attr + close', () => {
const tag = '<Tag a="x" disabled />';
const { result, changed } = fixTagAttrQuotes(tag);
assert.equal(changed, false);
assert.equal(result, tag);
});

test('fixNestedQuotesInBody skips fenced code blocks', () => {
// Use a realistic CJK artifact: a stray inner quote followed by non-ASCII
// text (what the translator actually produces). ASCII continuations are
// intentionally read as adjacent attributes, not inner quotes.
const body = [
'```jsx',
'<ZoomImage alt="甲"乙"丙" />',
'```',
'<ZoomImage alt="甲"乙"丙" />',
].join('\n');
const { result, changed } = fixNestedQuotesInBody(body);
assert.equal(changed, true);
const out = result.split('\n');
// Code-fence line (index 1) preserved verbatim; only the real tag (index 3) fixed.
assert.equal(out[1], '<ZoomImage alt="甲"乙"丙" />');
assert.equal(out[3], '<ZoomImage alt="甲\'乙\'丙" />');
});

const BROKEN_FILE = [
'---',
'title: "ok"',
'description: "带有、"显示所有方案"链接。"',
'---',
'',
'Intro.',
'<ZoomImage id="a.webp" alt="配置了"打开 URL"操作" />',
].join('\n');

test('fixNestedQuotes repairs frontmatter and body together', () => {
const { result, changed } = fixNestedQuotes(BROKEN_FILE);
assert.equal(changed, true);
assert.ok(result.includes('description: "带有、\'显示所有方案\'链接。"'));
assert.ok(result.includes('alt="配置了\'打开 URL\'操作"'));
});

test('parsesClean: false for broken file, true after fix', async () => {
assert.equal(await parsesClean(BROKEN_FILE), false);
const { result } = fixNestedQuotes(BROKEN_FILE);
assert.equal(await parsesClean(result), true);
});

test('fixNestedQuotes leaves a fully valid file unchanged', () => {
const ok = '---\ntitle: "ok"\n---\n\n<ZoomImage id="a.webp" alt="clean" />\n';
const { result, changed } = fixNestedQuotes(ok);
assert.equal(changed, false);
assert.equal(result, ok);
});
82 changes: 61 additions & 21 deletions scripts/check-mdx-parse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@

import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { compile } from '@mdx-js/mdx';
import remarkDirective from 'remark-directive';
import { remarkAside } from '../src/plugins/remark-aside.mjs';
import yaml from 'js-yaml';

const ROOT = process.cwd();
const SCAN_DIRS = ['src/content/docs', 'src/locales', 'src/components/reusable'];
Expand All @@ -42,9 +44,39 @@ async function* walk(dir) {
}
}

// Returns a parse-error descriptor for the file's YAML frontmatter, or null if
// the frontmatter is valid / absent. @mdx-js/mdx does not validate frontmatter,
// so this closes the gap that let bad-YAML translations reach the slow build.
export function frontmatterError(content) {
const m = content.match(/^?---\r?\n([\s\S]*?)\r?\n---/);
if (!m) return null;
try {
yaml.load(m[1]);
return null;
} catch (err) {
return {
message: err.message.split('\n')[0],
// js-yaml mark line is 0-based and relative to the frontmatter body;
// +2 maps it to the file (1 for the opening `---`, 1 for 1-based lines).
line: err.mark ? err.mark.line + 2 : null,
column: err.mark ? err.mark.column + 1 : null,
};
}
}

async function checkFile(file) {
const content = await fs.readFile(file, 'utf-8');
const fmErr = frontmatterError(content);
if (fmErr) {
return {
file: path.relative(ROOT, file),
message: `frontmatter: ${fmErr.message}`,
line: fmErr.line,
column: fmErr.column,
};
}
try {
await compile(await fs.readFile(file, 'utf-8'), {
await compile(content, {
jsx: true,
remarkPlugins: [remarkDirective, remarkAside],
});
Expand All @@ -59,29 +91,37 @@ async function checkFile(file) {
}
}

const files = [];
for (const dir of SCAN_DIRS) {
for await (const f of walk(path.join(ROOT, dir))) files.push(f);
}
async function main() {
const files = [];
for (const dir of SCAN_DIRS) {
for await (const f of walk(path.join(ROOT, dir))) files.push(f);
}

const issues = [];
for (let i = 0; i < files.length; i += CONCURRENCY) {
const chunk = files.slice(i, i + CONCURRENCY);
const results = await Promise.all(chunk.map(checkFile));
for (const r of results) if (r) issues.push(r);
}
const issues = [];
for (let i = 0; i < files.length; i += CONCURRENCY) {
const chunk = files.slice(i, i + CONCURRENCY);
const results = await Promise.all(chunk.map(checkFile));
for (const r of results) if (r) issues.push(r);
}

issues.sort((a, b) => a.file.localeCompare(b.file));
issues.sort((a, b) => a.file.localeCompare(b.file));

if (issues.length === 0) {
console.log(`check-mdx-parse: ${files.length} file(s) parsed cleanly`);
process.exit(0);
if (issues.length === 0) {
console.log(`check-mdx-parse: ${files.length} file(s) parsed cleanly`);
process.exit(0);
}

console.error(`check-mdx-parse: ${files.length} scanned, ${issues.length} parse error(s):\n`);
for (const i of issues) {
const loc = i.line ? `${i.file}:${i.line}${i.column ? ':' + i.column : ''}` : i.file;
console.error(` ${loc}`);
console.error(` ${i.message}\n`);
}
process.exit(1);
}

console.error(`check-mdx-parse: ${files.length} scanned, ${issues.length} parse error(s):\n`);
for (const i of issues) {
const loc = i.line ? `${i.file}:${i.line}${i.column ? ':' + i.column : ''}` : i.file;
console.error(` ${loc}`);
console.error(` ${i.message}\n`);
const isMain = process.argv[1]
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main();
}
process.exit(1);
Loading
Loading