mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
+80
@@ -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).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user