From b70edd2768e587b83af09717f4501d1e52d2a950 Mon Sep 17 00:00:00 2001 From: Kam Date: Mon, 31 Aug 2026 17:14:03 +0300 Subject: [PATCH] fix(docs-infra): parse docs-callout attributes correctly Three ways a callout could be misparsed: - A title quoted with `'` or a backtick was dropped, leaving an empty heading. Two callouts lose their title on angular.dev today, on guide/forms/template-driven-forms and guide/i18n/prepare. The first has to use single quotes because its title contains `"pristine"`. - A title containing `>` was dropped, because the attribute capture stopped at the first `>` even inside a quoted value. - The severity was matched anywhere in the tag, so a title such as "Why this is important" silently rendered an important callout. Scan attributes with quoting in mind, accept all three quote characters as #69268 did for docs-code-block, and match the severity flags against the tag with attribute values removed. The i18n callout also spelled the attribute `header`, which the extension has never read. --- .../shared/marked/extensions/docs-callout.mts | 19 ++++--- .../test/docs-callout/docs-callout.spec.mts | 52 +++++++++++++++++++ adev/src/content/guide/i18n/prepare.md | 2 +- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/adev/shared-docs/pipeline/shared/marked/extensions/docs-callout.mts b/adev/shared-docs/pipeline/shared/marked/extensions/docs-callout.mts index ad5f4d1d0de..3ceaeb67b6e 100644 --- a/adev/shared-docs/pipeline/shared/marked/extensions/docs-callout.mts +++ b/adev/shared-docs/pipeline/shared/marked/extensions/docs-callout.mts @@ -27,11 +27,13 @@ interface DocsCalloutToken extends Tokens.Generic { // Capture group 1: all attributes on the opening tag // Capture group 2: all content between the open and close tags -const calloutRule = /^]*)>((?:.(?!\/docs-callout))*)<\/docs-callout>/s; +const calloutRule = + /^"'`]|"[^"]*"|'[^']*'|`[^`]*`)*)>((?:.(?!\/docs-callout))*)<\/docs-callout>/s; -const titleRule = /title="([^"]*)"/; -const isImportantRule = /important/; -const isCriticalRule = /critical/; +const titleRule = /title=(['"`])(.*?)\1/; // The 2nd capture matters here +const attributeValueRule = /=(['"`])(?:.*?)\1/gs; +const isImportantRule = /\bimportant\b/; +const isCriticalRule = /\bcritical\b/; export const docsCalloutExtension = { name: 'docs-callout', @@ -46,9 +48,12 @@ export const docsCalloutExtension = { const attr = match[1].trim(); const title = titleRule.exec(attr); + // Severity is a bare word on the tag, so it must not be matched inside an attribute value. + const flags = attr.replace(attributeValueRule, ''); + let severityLevel = CalloutSeverityLevel.HELPFUL; - if (isImportantRule.exec(attr)) severityLevel = CalloutSeverityLevel.IMPORTANT; - if (isCriticalRule.exec(attr)) severityLevel = CalloutSeverityLevel.CRITICAL; + if (isImportantRule.exec(flags)) severityLevel = CalloutSeverityLevel.IMPORTANT; + if (isCriticalRule.exec(flags)) severityLevel = CalloutSeverityLevel.CRITICAL; const body = match[2].trim(); @@ -56,7 +61,7 @@ export const docsCalloutExtension = { type: 'docs-callout', raw: match[0], severityLevel: severityLevel, - title: title ? title[1] : '', + title: title ? title[2] : '', titleTokens: [], body: body ?? '', bodyTokens: [], diff --git a/adev/shared-docs/pipeline/shared/marked/test/docs-callout/docs-callout.spec.mts b/adev/shared-docs/pipeline/shared/marked/test/docs-callout/docs-callout.spec.mts index d190006e82e..735e3d17fa7 100644 --- a/adev/shared-docs/pipeline/shared/marked/test/docs-callout/docs-callout.spec.mts +++ b/adev/shared-docs/pipeline/shared/marked/test/docs-callout/docs-callout.spec.mts @@ -25,4 +25,56 @@ describe('markdown to html', () => { markdownDocument.querySelector('#default-marker')!.parentElement?.parentElement; calloutDiv?.classList.contains('docs-callout-helpful'); }); + + const parse = (markdown: string) => JSDOM.fragment(parseMarkdown(markdown, rendererContext)); + + it('reads a title quoted with any of the supported quote characters', () => { + for (const quote of ['"', "'", '`']) { + expect( + parse(`Body`).querySelector('h3') + ?.textContent, + ) + .withContext(`quoted with ${quote}`) + .toBe('Quoted'); + } + }); + + it('reads a title that contains the other quote characters', () => { + expect( + parse( + `Body`, + ).querySelector('h3')?.textContent, + ).toBe('Illustrating the "pristine" state'); + expect( + parse(`Body`).querySelector('h3')?.textContent, + ).toBe("It's here"); + }); + + it('reads a title that contains a closing angle bracket', () => { + expect( + parse('Body').querySelector('h3') + ?.textContent, + ).toBe('Migrate a > b'); + }); + + it('takes the severity from the tag, not from the title text', () => { + expect( + parse('Body').querySelector( + '.docs-callout', + )?.className, + ).toBe('docs-callout docs-callout-helpful'); + expect( + parse('Body').querySelector( + '.docs-callout', + )?.className, + ).toBe('docs-callout docs-callout-helpful'); + expect( + parse('Body').querySelector('.docs-callout') + ?.className, + ).toBe('docs-callout docs-callout-important'); + expect( + parse('Body').querySelector('.docs-callout') + ?.className, + ).toBe('docs-callout docs-callout-critical'); + }); }); diff --git a/adev/src/content/guide/i18n/prepare.md b/adev/src/content/guide/i18n/prepare.md index 5b32a3422c6..95d6e393cb1 100644 --- a/adev/src/content/guide/i18n/prepare.md +++ b/adev/src/content/guide/i18n/prepare.md @@ -265,7 +265,7 @@ other { default_quantity } HELPFUL: For more information about pluralization categories, see [Choosing plural category names][UnicodeCldrIndexCldrSpecPluralRulesTocChoosingPluralCategoryNames] in the [CLDR - Unicode Common Locale Data Repository][UnicodeCldrMain]. - + Many locales don't support some of the pluralization categories. The default locale \(`en-US`\) uses a very simple `plural()` function that doesn't support the `few` pluralization category.