fix(docs-infra): reject unclosed paired docs elements

An unclosed `<docs-step>` or `<docs-card>` produces no error, just wrong
output, so the two cases fixed in the previous commit went unnoticed for
years.

Validate that the paired custom elements balance before parsing. A
mismatch now names the file and the counts instead of silently
swallowing a section.

(cherry picked from commit d9afca095b)
This commit is contained in:
Kam
2026-08-31 14:37:16 +03:00
committed by Matthew Beck
parent 5cf7764e1b
commit 341bc0deb7
3 changed files with 127 additions and 0 deletions
@@ -24,6 +24,7 @@ import {docsCodeExtension, DocsCodeToken} from './extensions/docs-code/docs-code
import {docsCodeMultifileExtension} from './extensions/docs-code/docs-code-multifile.mjs';
import {docsTabGroupExtension, docsTabExtension} from './extensions/docs-tabs.mjs';
import {docsImageExtension} from './extensions/docs-image.mjs';
import {validatePairedTags} from './validate-paired-tags.mjs';
let markedInstance: typeof marked;
const extensions = [
@@ -51,11 +52,13 @@ export async function parseMarkdownAsync(
markdownContent: string,
context: Partial<RendererContext>,
): Promise<string> {
validatePairedTags(markdownContent, context.markdownFilePath);
markedInstance ??= marked.use({extensions, walkTokens, async: true});
return markedInstance.parse(markdownContent, {renderer: new AdevDocsRenderer(context)});
}
export function parseMarkdown(markdownContent: string, context: Partial<RendererContext>): string {
validatePairedTags(markdownContent, context.markdownFilePath);
markedInstance ??= marked.use({extensions, walkTokens});
return markedInstance.parse(markdownContent, {renderer: new AdevDocsRenderer(context)}) as string;
}
@@ -0,0 +1,80 @@
/**
* @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 {parseMarkdown} from '../../parse.mjs';
import {rendererContext, setHighlighter} from '../renderer-context.mjs';
describe('paired tag validation', () => {
beforeAll(async () => {
await setHighlighter();
});
const parse = (markdown: string) =>
parseMarkdown(markdown, {...rendererContext, markdownFilePath: 'guide/example.md'});
it('throws when a step is left unclosed', () => {
expect(() =>
parse(`
<docs-workflow>
<docs-step title="First">
First step.
<docs-step title="Second">
Second step.
</docs-step>
</docs-workflow>`),
).toThrowError(
/Unbalanced <docs-step> in guide\/example\.md: 2 opening tag\(s\) and 1 closing/,
);
});
it('throws when a workflow is left unclosed', () => {
expect(() =>
parse(`
<docs-workflow>
<docs-step title="Only">
Only step.
</docs-step>`),
).toThrowError(/Unbalanced <docs-workflow>/);
});
it('accepts balanced tags', () => {
expect(() =>
parse(`
<docs-workflow>
<docs-step title="Only">
Only step.
</docs-step>
</docs-workflow>`),
).not.toThrow();
});
it('ignores tags inside code blocks, inline code and comments', () => {
expect(() =>
parse(
[
'```html',
'<docs-step title="In a fence">',
'```',
'',
'Steps are written as `<docs-step>` elements.',
'',
'<!-- <docs-workflow> -->',
].join('\n'),
),
).not.toThrow();
});
it('does not confuse docs-card with docs-card-container', () => {
expect(() =>
parse(`
<docs-card-container>
<docs-card title="One">Body</docs-card>
</docs-card-container>`),
).not.toThrow();
});
});
@@ -0,0 +1,44 @@
/*!
* @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
*/
/** Custom elements that are always written as a matching pair of open and close tags. */
const PAIRED_TAGS = [
'docs-callout',
'docs-card',
'docs-card-container',
'docs-decorative-header',
'docs-step',
'docs-tab',
'docs-tab-group',
'docs-workflow',
];
/**
* Throws when a paired custom element is left unclosed.
*
* The tokenizers match up to the next closing tag rather than failing, so an unclosed tag pulls
* the content that follows it into the block and leaves the next block unparsed in the output.
*/
export function validatePairedTags(markdown: string, filePath?: string): void {
const content = markdown
.replace(/```[\s\S]*?```/g, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/`[^`\n]*`/g, '');
for (const tag of PAIRED_TAGS) {
const opened = content.match(new RegExp(`<${tag}(?=[\\s>])(?![^>]*/>)`, 'g'))?.length ?? 0;
const closed = content.match(new RegExp(`</${tag}>`, 'g'))?.length ?? 0;
if (opened !== closed) {
throw new Error(
`Unbalanced <${tag}> in ${filePath || 'markdown'}: ` +
`${opened} opening tag(s) and ${closed} closing tag(s).`,
);
}
}
}