466 lines
14 KiB
JavaScript
466 lines
14 KiB
JavaScript
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
import { dirname, extname, join, resolve } from 'node:path';
|
|
import {
|
|
formatWorkspacePath as formatPath,
|
|
isInsideDir,
|
|
listFiles,
|
|
readJsonFile,
|
|
readWebsiteLocales,
|
|
statSafe,
|
|
workspaceRoot,
|
|
} from './package-utils.mjs';
|
|
|
|
const root = workspaceRoot;
|
|
const websiteSource = resolve(root, 'website/src');
|
|
const manualRoot = join(websiteSource, 'content/manual');
|
|
const manualAssetRoot = join(manualRoot, 'assets');
|
|
const examplesRoot = join(websiteSource, 'content/examples');
|
|
const apiManifestPath = resolve(root, 'website/.generated/api/manifest.ts');
|
|
const bannedLinkPattern = /(?:https?:\/\/go\.|go\.\/|cf\.qunhequnhe|pages\/viewpage|display\/EGS)/i;
|
|
const imagePattern = /!\[[^\]]*]\(([^)]+)\)/g;
|
|
const markdownLinkPattern = /(^|[^!])\[[^\]]*]\(([^)]+)\)/g;
|
|
const htmlHrefPattern = /\bhref=(["'])([^"']+)\1/g;
|
|
const apiReferencePrefix = 'api:';
|
|
|
|
const errors = [];
|
|
const locales = readWebsiteLocales();
|
|
const defaultLocale = locales[0];
|
|
const apiEntriesBySymbol = createApiEntriesBySymbol(readApiManifest());
|
|
|
|
checkManualContent();
|
|
checkExamples();
|
|
checkManualImages();
|
|
checkManualLinks();
|
|
checkBannedLinks();
|
|
|
|
if (errors.length > 0) {
|
|
console.error(`[content-check] Found ${errors.length} issue(s):`);
|
|
for (const error of errors) {
|
|
console.error(`- ${error}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`[content-check] OK: ${locales.length} locales, manual pages, examples, links, and assets are consistent.`);
|
|
|
|
function checkManualContent() {
|
|
const manualByLocale = new Map();
|
|
|
|
for (const locale of locales) {
|
|
const localeDir = join(manualRoot, locale);
|
|
|
|
if (!existsSync(localeDir)) {
|
|
errors.push(`Missing manual locale directory: ${formatPath(localeDir)}`);
|
|
continue;
|
|
}
|
|
|
|
const entries = new Map();
|
|
for (const fileName of readdirSync(localeDir)
|
|
.filter(file => file.endsWith('.md'))
|
|
.sort()) {
|
|
const slug = fileName.replace(/\.md$/, '');
|
|
const filePath = join(localeDir, fileName);
|
|
const content = readFileSync(filePath, 'utf8');
|
|
const parsed = parseMarkdown(filePath, content);
|
|
|
|
if (slug.includes('tranform')) {
|
|
errors.push(`Manual slug contains misspelling "tranform": ${formatPath(filePath)}`);
|
|
}
|
|
|
|
if (content.trim() === '') {
|
|
errors.push(`Manual page is empty: ${formatPath(filePath)}`);
|
|
}
|
|
|
|
if (!parsed.frontmatter.title) {
|
|
errors.push(`Manual page is missing frontmatter title: ${formatPath(filePath)}`);
|
|
}
|
|
|
|
if (!parsed.frontmatter.description) {
|
|
errors.push(`Manual page is missing frontmatter description: ${formatPath(filePath)}`);
|
|
}
|
|
|
|
if (!Number.isFinite(Number(parsed.frontmatter.order))) {
|
|
errors.push(`Manual page is missing numeric frontmatter order: ${formatPath(filePath)}`);
|
|
}
|
|
|
|
if (parsed.body.trim() === '') {
|
|
errors.push(`Manual page has no body content: ${formatPath(filePath)}`);
|
|
}
|
|
|
|
entries.set(slug, {
|
|
filePath,
|
|
headingSignature: getHeadingSignature(content),
|
|
});
|
|
}
|
|
|
|
manualByLocale.set(locale, entries);
|
|
}
|
|
|
|
const baseEntries = manualByLocale.get(defaultLocale);
|
|
if (!baseEntries) {
|
|
return;
|
|
}
|
|
|
|
const baseSlugs = [...baseEntries.keys()].sort();
|
|
|
|
for (const locale of locales.slice(1)) {
|
|
const localizedEntries = manualByLocale.get(locale);
|
|
if (!localizedEntries) {
|
|
continue;
|
|
}
|
|
|
|
const localizedSlugs = [...localizedEntries.keys()].sort();
|
|
const missing = baseSlugs.filter(slug => !localizedEntries.has(slug));
|
|
const extra = localizedSlugs.filter(slug => !baseEntries.has(slug));
|
|
|
|
for (const slug of missing) {
|
|
errors.push(`Manual page "${slug}" exists in ${defaultLocale} but is missing in ${locale}.`);
|
|
}
|
|
|
|
for (const slug of extra) {
|
|
errors.push(`Manual page "${slug}" exists in ${locale} but is missing in ${defaultLocale}.`);
|
|
}
|
|
|
|
for (const slug of baseSlugs.filter(item => localizedEntries.has(item))) {
|
|
const baseSignature = baseEntries.get(slug).headingSignature;
|
|
const localizedSignature = localizedEntries.get(slug).headingSignature;
|
|
|
|
if (baseSignature !== localizedSignature) {
|
|
errors.push(
|
|
`Manual heading depth structure differs for "${slug}" between ${defaultLocale} (${baseSignature}) and ${locale} (${localizedSignature}).`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkBannedLinks() {
|
|
const scanTargets = [
|
|
join(root, 'README.md'),
|
|
join(root, 'docs'),
|
|
join(root, 'packages/renderer/README.md'),
|
|
join(root, 'website/src/content'),
|
|
];
|
|
|
|
for (const target of scanTargets) {
|
|
const files = statSafe(target)?.isDirectory() ? listFiles(target) : [target];
|
|
|
|
for (const filePath of files.filter(file => ['.md', '.mdx'].includes(extname(file).toLowerCase()))) {
|
|
if (!existsSync(filePath)) {
|
|
continue;
|
|
}
|
|
|
|
const content = readFileSync(filePath, 'utf8');
|
|
|
|
if (bannedLinkPattern.test(content)) {
|
|
errors.push(`Content contains an internal or invalid link: ${formatPath(filePath)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkExamples() {
|
|
if (!existsSync(examplesRoot)) {
|
|
errors.push(`Missing examples directory: ${formatPath(examplesRoot)}`);
|
|
return;
|
|
}
|
|
|
|
const files = readdirSync(examplesRoot);
|
|
const jsonSlugs = files
|
|
.filter(file => file.endsWith('.json'))
|
|
.map(file => file.replace(/\.json$/, ''))
|
|
.sort();
|
|
const sourceSlugs = files
|
|
.filter(file => file.endsWith('.ts'))
|
|
.map(file => file.replace(/\.ts$/, ''))
|
|
.sort();
|
|
|
|
for (const slug of jsonSlugs.filter(item => !sourceSlugs.includes(item))) {
|
|
errors.push(`Example metadata is missing a TypeScript source file: ${slug}.json`);
|
|
}
|
|
|
|
for (const slug of sourceSlugs.filter(item => !jsonSlugs.includes(item))) {
|
|
errors.push(`Example TypeScript source is missing JSON metadata: ${slug}.ts`);
|
|
}
|
|
|
|
for (const slug of jsonSlugs) {
|
|
const filePath = join(examplesRoot, `${slug}.json`);
|
|
const metadata = readJson(filePath);
|
|
|
|
if (!metadata) {
|
|
continue;
|
|
}
|
|
|
|
for (const locale of locales) {
|
|
if (!metadata.title?.[locale]) {
|
|
errors.push(`Example "${slug}" is missing title for ${locale}.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkManualImages() {
|
|
const usedImages = new Set();
|
|
|
|
for (const filePath of listFiles(manualRoot).filter(file => file.endsWith('.md'))) {
|
|
const content = readFileSync(filePath, 'utf8');
|
|
|
|
for (const match of content.matchAll(imagePattern)) {
|
|
const target = parseMarkdownTarget(match[1]);
|
|
|
|
if (!target || isExternalReference(target.href)) {
|
|
continue;
|
|
}
|
|
|
|
if (target.href.startsWith('/manual/')) {
|
|
errors.push(
|
|
`Manual image should use a local relative path for editor previews: ${target.href} referenced by ${formatPath(filePath)}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (!isLocalReference(target.href)) {
|
|
continue;
|
|
}
|
|
|
|
const { pathname } = splitPathnameAndTrailing(target.href);
|
|
const imagePath = resolve(dirname(filePath), decodeURIComponent(pathname));
|
|
|
|
if (!isInsideDir(manualAssetRoot, imagePath)) {
|
|
errors.push(
|
|
`Manual image must live under website/src/content/manual/assets: ${target.href} referenced by ${formatPath(filePath)}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
usedImages.add(formatManualAssetPath(imagePath));
|
|
|
|
if (!existsSync(imagePath)) {
|
|
errors.push(`Manual image is missing: ${target.href} referenced by ${formatPath(filePath)}`);
|
|
continue;
|
|
}
|
|
|
|
if (!isImageFile(imagePath)) {
|
|
errors.push(
|
|
`Manual image reference is not an image file: ${target.href} referenced by ${formatPath(filePath)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const imagePath of listFiles(manualAssetRoot).filter(isImageFile)) {
|
|
const normalized = formatManualAssetPath(imagePath);
|
|
|
|
if (!usedImages.has(normalized)) {
|
|
errors.push(`Manual image is not referenced by any manual page: ${normalized}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkManualLinks() {
|
|
for (const filePath of listFiles(manualRoot).filter(file => file.endsWith('.md'))) {
|
|
const content = readFileSync(filePath, 'utf8');
|
|
|
|
for (const target of getManualLinkTargets(content)) {
|
|
const parsed = parseMarkdownTarget(target);
|
|
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
|
|
if (isApiReference(parsed.href)) {
|
|
checkManualApiLink(parsed.href, target, filePath);
|
|
continue;
|
|
}
|
|
|
|
if (!isLocalReference(parsed.href)) {
|
|
continue;
|
|
}
|
|
|
|
const { pathname } = splitPathnameAndTrailing(parsed.href);
|
|
|
|
if (extname(pathname).toLowerCase() !== '.md') {
|
|
continue;
|
|
}
|
|
|
|
const linkedPath = resolve(dirname(filePath), decodeURIComponent(pathname));
|
|
|
|
if (!isInsideDir(manualRoot, linkedPath)) {
|
|
errors.push(
|
|
`Manual Markdown link must point to a manual page: ${target} referenced by ${formatPath(filePath)}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (!existsSync(linkedPath)) {
|
|
errors.push(`Manual page link is missing: ${target} referenced by ${formatPath(filePath)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkManualApiLink(href, target, filePath) {
|
|
const { pathname } = splitPathnameAndTrailing(href);
|
|
const symbol = decodeURIComponent(pathname.slice(apiReferencePrefix.length)).trim();
|
|
|
|
if (!symbol) {
|
|
errors.push(`Manual API link is missing a symbol: ${target} referenced by ${formatPath(filePath)}`);
|
|
return;
|
|
}
|
|
|
|
const entries = apiEntriesBySymbol.get(symbol) ?? [];
|
|
|
|
if (entries.length === 0) {
|
|
errors.push(`Manual API link target not found: ${target} referenced by ${formatPath(filePath)}`);
|
|
return;
|
|
}
|
|
|
|
if (entries.length > 1) {
|
|
errors.push(
|
|
`Manual API link target is ambiguous: ${target} referenced by ${formatPath(filePath)}. Use Namespace.Symbol.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function getManualLinkTargets(content) {
|
|
const targets = [];
|
|
|
|
for (const match of content.matchAll(markdownLinkPattern)) {
|
|
targets.push(match[2]);
|
|
}
|
|
|
|
for (const match of content.matchAll(htmlHrefPattern)) {
|
|
targets.push(match[2]);
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
function parseMarkdown(filePath, content) {
|
|
if (!content.startsWith('---')) {
|
|
return {
|
|
frontmatter: {},
|
|
body: content,
|
|
};
|
|
}
|
|
|
|
const endMatch = content.slice(3).match(/\r?\n---\r?\n/);
|
|
if (!endMatch || endMatch.index === undefined) {
|
|
errors.push(`Manual page has unterminated frontmatter: ${formatPath(filePath)}`);
|
|
return {
|
|
frontmatter: {},
|
|
body: content,
|
|
};
|
|
}
|
|
|
|
const frontmatterEnd = 3 + endMatch.index;
|
|
const delimiterLength = endMatch[0].length;
|
|
const frontmatterText = content.slice(3, frontmatterEnd);
|
|
const body = content.slice(frontmatterEnd + delimiterLength);
|
|
const frontmatter = {};
|
|
|
|
for (const line of frontmatterText.split(/\r?\n/)) {
|
|
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
|
|
frontmatter[match[1]] = match[2].replace(/^["']|["']$/g, '').trim();
|
|
}
|
|
|
|
return {
|
|
frontmatter,
|
|
body,
|
|
};
|
|
}
|
|
|
|
function getHeadingSignature(content) {
|
|
return content
|
|
.split(/\r?\n/)
|
|
.map(line => line.match(/^(#{2,6})\s+\S/))
|
|
.filter(Boolean)
|
|
.map(match => match[1].length)
|
|
.join(',');
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
try {
|
|
return readJsonFile(filePath, 'example metadata');
|
|
} catch (error) {
|
|
errors.push(`Unable to parse JSON at ${formatPath(filePath)}: ${error.message}`);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function readApiManifest() {
|
|
const source = readFileSync(apiManifestPath, 'utf8');
|
|
const match = source.match(/^export const apiManifest = ([\s\S]*?) as const;\s*export type /);
|
|
|
|
if (!match) {
|
|
throw new Error(`Unable to parse API manifest at ${formatPath(apiManifestPath)}.`);
|
|
}
|
|
|
|
return JSON.parse(match[1]);
|
|
}
|
|
|
|
function createApiEntriesBySymbol(manifest) {
|
|
const entriesBySymbol = new Map();
|
|
|
|
for (const entry of manifest.entries ?? []) {
|
|
addApiEntrySymbol(entriesBySymbol, entry.title, entry);
|
|
addApiEntrySymbol(entriesBySymbol, `${entry.namespace}.${entry.title}`, entry);
|
|
}
|
|
|
|
return entriesBySymbol;
|
|
}
|
|
|
|
function addApiEntrySymbol(entriesBySymbol, symbol, entry) {
|
|
const entries = entriesBySymbol.get(symbol);
|
|
|
|
if (entries) {
|
|
entries.push(entry);
|
|
return;
|
|
}
|
|
|
|
entriesBySymbol.set(symbol, [entry]);
|
|
}
|
|
|
|
function isImageFile(filePath) {
|
|
return ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'].includes(extname(filePath).toLowerCase());
|
|
}
|
|
|
|
function parseMarkdownTarget(target) {
|
|
const trimmed = target.trim();
|
|
const match = trimmed.match(/^(\S+)(.*)$/s);
|
|
|
|
return match ? { href: match[1], suffix: match[2] } : undefined;
|
|
}
|
|
|
|
function isExternalReference(href) {
|
|
return /^[a-z][a-z\d+.-]*:/i.test(href);
|
|
}
|
|
|
|
function isApiReference(href) {
|
|
const { pathname } = splitPathnameAndTrailing(href);
|
|
|
|
return pathname.startsWith(apiReferencePrefix);
|
|
}
|
|
|
|
function isLocalReference(href) {
|
|
return !href.startsWith('/') && !href.startsWith('#') && !isExternalReference(href);
|
|
}
|
|
|
|
function splitPathnameAndTrailing(href) {
|
|
const match = href.match(/^([^?#]*)([?#].*)?$/s);
|
|
|
|
return {
|
|
pathname: match?.[1] ?? href,
|
|
trailing: match?.[2] ?? '',
|
|
};
|
|
}
|
|
|
|
function formatManualAssetPath(filePath) {
|
|
return formatPath(filePath).replace(/^website\/src\/content\/manual\//, 'manual/');
|
|
}
|