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.
This commit is contained in:
Kam
2026-08-31 17:14:03 +03:00
committed by Matthew Beck
parent 00ec65546b
commit b70edd2768
3 changed files with 65 additions and 8 deletions
@@ -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))*)<\/docs-callout>/s;
const calloutRule =
/^<docs-callout((?:[^>"'`]|"[^"]*"|'[^']*'|`[^`]*`)*)>((?:.(?!\/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: [],
@@ -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(`<docs-callout title=${quote}Quoted${quote}>Body</docs-callout>`).querySelector('h3')
?.textContent,
)
.withContext(`quoted with ${quote}`)
.toBe('Quoted');
}
});
it('reads a title that contains the other quote characters', () => {
expect(
parse(
`<docs-callout title='Illustrating the "pristine" state'>Body</docs-callout>`,
).querySelector('h3')?.textContent,
).toBe('Illustrating the "pristine" state');
expect(
parse(`<docs-callout title="It's here">Body</docs-callout>`).querySelector('h3')?.textContent,
).toBe("It's here");
});
it('reads a title that contains a closing angle bracket', () => {
expect(
parse('<docs-callout title="Migrate a > b">Body</docs-callout>').querySelector('h3')
?.textContent,
).toBe('Migrate a > b');
});
it('takes the severity from the tag, not from the title text', () => {
expect(
parse('<docs-callout title="Why this is important">Body</docs-callout>').querySelector(
'.docs-callout',
)?.className,
).toBe('docs-callout docs-callout-helpful');
expect(
parse('<docs-callout title="A critical note">Body</docs-callout>').querySelector(
'.docs-callout',
)?.className,
).toBe('docs-callout docs-callout-helpful');
expect(
parse('<docs-callout important title="X">Body</docs-callout>').querySelector('.docs-callout')
?.className,
).toBe('docs-callout docs-callout-important');
expect(
parse('<docs-callout critical title="X">Body</docs-callout>').querySelector('.docs-callout')
?.className,
).toBe('docs-callout docs-callout-critical');
});
});
+1 -1
View File
@@ -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].
<docs-callout header='Background: Locales may not support some pluralization categories'>
<docs-callout title="Background: Locales may not support some pluralization categories">
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.