Files
Kam ab52df470a fix(docs-infra): fail the guide build when a markdown file starts with a BOM
A leading UTF-8 byte order mark (U+FEFF) before the first `#` stops the
Markdown parser from recognizing the heading, so the guide renders its
title as a paragraph and drops the standard docs header. The character
is invisible, so it cannot be caught in review.

Add a check in the guides generation pipeline that throws when a source
file starts with a BOM, failing the build with the offending file name.
This sits alongside the existing unknown-anchor check and prevents the
regression fixed in #69889 from recurring.
2026-07-22 14:26:19 +02:00

125 lines
4.2 KiB
TypeScript

/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {readFile, writeFile} from 'fs/promises';
import path from 'path';
import {parseMarkdownAsync} from '../shared/marked/parse.mjs';
import {initHighlighter} from '../shared/shiki.mjs';
import {hasUnknownAnchors} from './helpers.mjs';
type ApiManifest = ApiManifestPackage[];
interface ApiManifestPackage {
moduleName: string;
entries: {name: string; aliases?: string[]}[];
}
async function main() {
const [paramFilePath] = process.argv.slice(2);
const rawParamLines = (await readFile(paramFilePath, {encoding: 'utf8'})).split('\n');
const [srcs, outputFilenameExecRootRelativePath, apiManifestPath, definedRoutesAsStr] =
rawParamLines;
// The highlighter needs to be setup asynchronously
// so we're doing it at the start of the pipeline
const highlighter = await initHighlighter();
let apiManifest: ApiManifest = [];
if (!apiManifestPath) {
throw new Error(
'No API manifest path provided to the markdown parser. Check the failing generate_guides target.',
);
}
const apiManifestStr = await readFile(apiManifestPath, {encoding: 'utf8'});
apiManifest = JSON.parse(apiManifestStr);
let definedRoutes: string[] = [];
if (!definedRoutesAsStr) {
throw new Error(
'No defined routes path provided to the markdown parser. Check the failing generate_guides target.',
);
}
const definedGuideRoutes = await readFile(definedRoutesAsStr, {encoding: 'utf8'});
definedRoutes = JSON.parse(definedGuideRoutes) as string[];
await Promise.all(
srcs.split(',').map(async (filePath) => {
if (!filePath.endsWith('.md')) {
throw new Error(`Input file "${filePath}" does not end in a ".md" file extension.`);
}
const markdownContent = await readFile(filePath, {encoding: 'utf8'});
// A leading byte order mark (U+FEFF) stops the first Markdown heading from being
// recognized, so the page title renders as a paragraph and loses its header. Fail
// the build so the BOM has to be removed from the source file instead.
if (markdownContent.charCodeAt(0) === 0xfeff) {
throw new Error(
`The file "${filePath}" starts with a byte order mark (BOM). Remove it so the leading heading is parsed correctly.`,
);
}
const htmlOutputContent = await parseMarkdownAsync(markdownContent, {
markdownFilePath: filePath,
apiEntries: mapManifestToEntries(apiManifest),
highlighter,
definedRoutes,
});
// The expected file name structure is the [name of the file].md.html.
const htmlFileName = filePath + '.html';
const htmlOutputPath = path.join(outputFilenameExecRootRelativePath, htmlFileName);
const unknownAnchor = hasUnknownAnchors(htmlOutputContent);
if (unknownAnchor) {
throw new Error(
`The file "${filePath}" contains an anchor link to "${unknownAnchor}" which does not exist in the document.`,
);
}
await writeFile(htmlOutputPath, htmlOutputContent, {encoding: 'utf8'});
}),
);
}
main();
function mapManifestToEntries(
apiManifest: ApiManifest,
): Record<string, {moduleName: string; targetSymbol?: string}> {
const duplicateEntries = new Set<string>();
const entryToModuleMap: Record<string, {moduleName: string; targetSymbol?: string}> = {};
for (const pkg of apiManifest) {
for (const entry of pkg.entries) {
if (duplicateEntries.has(entry.name)) {
continue;
} else if (entryToModuleMap[entry.name]) {
delete entryToModuleMap[entry.name];
duplicateEntries.add(entry.name);
} else {
const normalizedModuleName = pkg.moduleName.replace(/^@angular\//, '');
entryToModuleMap[entry.name] = {moduleName: normalizedModuleName};
// If there are aliases, create entries for each alias
if (entry.aliases) {
for (const alias of entry.aliases) {
entryToModuleMap[alias] = {
moduleName: normalizedModuleName,
targetSymbol: entry.name,
};
}
}
}
}
}
return entryToModuleMap;
}