From 341bc0deb7fc5d88bf955dc8680c415999e734d7 Mon Sep 17 00:00:00 2001 From: Kam Date: Mon, 31 Aug 2026 14:37:16 +0300 Subject: [PATCH] fix(docs-infra): reject unclosed paired docs elements An unclosed `` or `` 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 d9afca095b42f03779bdf1ed687d90908eae8a81) --- .../pipeline/shared/marked/parse.mts | 3 + .../validate-paired-tags.spec.mts | 80 +++++++++++++++++++ .../shared/marked/validate-paired-tags.mts | 44 ++++++++++ 3 files changed, 127 insertions(+) create mode 100644 adev/shared-docs/pipeline/shared/marked/test/docs-workflow/validate-paired-tags.spec.mts create mode 100644 adev/shared-docs/pipeline/shared/marked/validate-paired-tags.mts diff --git a/adev/shared-docs/pipeline/shared/marked/parse.mts b/adev/shared-docs/pipeline/shared/marked/parse.mts index c1301fe970f..3d9cdb5b420 100644 --- a/adev/shared-docs/pipeline/shared/marked/parse.mts +++ b/adev/shared-docs/pipeline/shared/marked/parse.mts @@ -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, ): Promise { + 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): string { + validatePairedTags(markdownContent, context.markdownFilePath); markedInstance ??= marked.use({extensions, walkTokens}); return markedInstance.parse(markdownContent, {renderer: new AdevDocsRenderer(context)}) as string; } diff --git a/adev/shared-docs/pipeline/shared/marked/test/docs-workflow/validate-paired-tags.spec.mts b/adev/shared-docs/pipeline/shared/marked/test/docs-workflow/validate-paired-tags.spec.mts new file mode 100644 index 00000000000..4fcc88e7c8d --- /dev/null +++ b/adev/shared-docs/pipeline/shared/marked/test/docs-workflow/validate-paired-tags.spec.mts @@ -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(` + + +First step. + +Second step. + +`), + ).toThrowError( + /Unbalanced in guide\/example\.md: 2 opening tag\(s\) and 1 closing/, + ); + }); + + it('throws when a workflow is left unclosed', () => { + expect(() => + parse(` + + +Only step. +`), + ).toThrowError(/Unbalanced /); + }); + + it('accepts balanced tags', () => { + expect(() => + parse(` + + +Only step. + +`), + ).not.toThrow(); + }); + + it('ignores tags inside code blocks, inline code and comments', () => { + expect(() => + parse( + [ + '```html', + '', + '```', + '', + 'Steps are written as `` elements.', + '', + '', + ].join('\n'), + ), + ).not.toThrow(); + }); + + it('does not confuse docs-card with docs-card-container', () => { + expect(() => + parse(` + +Body +`), + ).not.toThrow(); + }); +}); diff --git a/adev/shared-docs/pipeline/shared/marked/validate-paired-tags.mts b/adev/shared-docs/pipeline/shared/marked/validate-paired-tags.mts new file mode 100644 index 00000000000..a462f3ca179 --- /dev/null +++ b/adev/shared-docs/pipeline/shared/marked/validate-paired-tags.mts @@ -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(//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(``, 'g'))?.length ?? 0; + + if (opened !== closed) { + throw new Error( + `Unbalanced <${tag}> in ${filePath || 'markdown'}: ` + + `${opened} opening tag(s) and ${closed} closing tag(s).`, + ); + } + } +}