@zombie-mermaid/core 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/core",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared types, theme, and text/statement utilities used by every zombie-mermaid renderer.",
6
6
  "repository": {
@@ -59,6 +59,21 @@ describe('detectDiagramType', () => {
59
59
  expect(detectDiagramType('xychart-foo\nx-axis [a, b]')).toBe('flowchart')
60
60
  })
61
61
 
62
+ it('detects pie charts with or without trailing header content', () => {
63
+ expect(detectDiagramType('pie\n"A" : 1')).toBe('pie')
64
+ expect(detectDiagramType(' pie ')).toBe('pie')
65
+ expect(detectDiagramType('pie showData\n"A" : 1')).toBe('pie')
66
+ expect(detectDiagramType('pie title Pets\n"A" : 1')).toBe('pie')
67
+ expect(detectDiagramType('pie\ttitle Pets')).toBe('pie')
68
+ expect(detectDiagramType('%% comment\npie\n"A" : 1')).toBe('pie')
69
+ })
70
+
71
+ it('matches the pie header case-sensitively and as a whole word, like Mermaid', () => {
72
+ expect(detectDiagramType('Pie\n"A" : 1')).toBe('flowchart')
73
+ expect(detectDiagramType('PIE\n"A" : 1')).toBe('flowchart')
74
+ expect(detectDiagramType('pieChart\n"A" : 1')).toBe('flowchart')
75
+ })
76
+
62
77
  it('falls back to flowchart for unrecognized input', () => {
63
78
  expect(detectDiagramType('')).toBe('flowchart')
64
79
  expect(detectDiagramType('not a real header')).toBe('flowchart')
@@ -25,6 +25,7 @@ export const DIAGRAM_TYPES = [
25
25
  'architecture',
26
26
 
27
27
  'c4',
28
+ 'pie',
28
29
  ] as const
29
30
 
30
31
  /** The diagram types this library can detect and route to a renderer. */
@@ -40,7 +41,14 @@ export type DiagramType = (typeof DIAGRAM_TYPES)[number]
40
41
  * routing and parsing can never disagree about where the header ends.
41
42
  */
42
43
  export function detectDiagramType(text: string): DiagramType {
43
- const firstLine = splitStatements(text)[0]?.text.toLowerCase() ?? ''
44
+ const header = splitStatements(text)[0]?.text ?? ''
45
+ const firstLine = header.toLowerCase()
46
+
47
+ // Case-sensitive, unlike the other headers: Mermaid's pie detector is
48
+ // `/^\s*pie/` and its grammar keyword `pie` must be followed by
49
+ // whitespace, a comment, or the end of input, so `Pie` and `pie:` are not
50
+ // pie charts there either.
51
+ if (/^pie(?:\s|$)/.test(header)) return 'pie'
44
52
 
45
53
  if (/^xychart(?:-beta)?(?:\s|$)/.test(firstLine)) return 'xychart'
46
54
  if (/^architecture(?:-beta)?\s*$/.test(firstLine)) return 'architecture'
@@ -30,7 +30,7 @@ export function normalizeBrTags(label: string): string {
30
30
  ? unquoted.slice(1, -1)
31
31
  : unquoted
32
32
 
33
- return (
33
+ return decodeMermaidEntities(
34
34
  unfenced
35
35
  .replace(/<br\s*\/?>/gi, '\n')
36
36
  .replace(/\\n/g, '\n')
@@ -38,7 +38,71 @@ export function normalizeBrTags(label: string): string {
38
38
  // Markdown formatting → HTML tags (order matters: ** before *)
39
39
  .replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
40
40
  .replace(/(?<!\*)\*([^\s*](?:[^*]*[^\s*])?)\*(?!\*)/g, '<i>$1</i>')
41
- .replace(/~~(.+?)~~/g, '<s>$1</s>')
41
+ .replace(/~~(.+?)~~/g, '<s>$1</s>'),
42
+ )
43
+ }
44
+
45
+ const MAX_CODEPOINT = 0x10ffff
46
+
47
+ const NAMED_ENTITIES: Record<string, string> = {
48
+ quot: '"',
49
+ amp: '&',
50
+ lt: '<',
51
+ gt: '>',
52
+ apos: "'",
53
+ nbsp: '\u00a0',
54
+ }
55
+
56
+ /** Code point -> character, or undefined when it is not a usable scalar value. */
57
+ function codePointToChar(cp: number): string | undefined {
58
+ const usable =
59
+ Number.isInteger(cp) &&
60
+ cp > 0 &&
61
+ cp <= MAX_CODEPOINT &&
62
+ !(cp >= 0xd800 && cp <= 0xdfff)
63
+ return usable ? String.fromCodePoint(cp) : undefined
64
+ }
65
+
66
+ /**
67
+ * Decode Mermaid's entity codes (`#quot;`, `#lt;`, `#35;`, `#x5B;`), the way
68
+ * Mermaid writes characters that would otherwise end a label or break its
69
+ * syntax. Call it on a single already-parsed label, never on diagram source:
70
+ * decoding up front would let the characters it produces (`<`, `"`, `[`)
71
+ * change how the line parses, and would hit `#fff;` colors in style lines.
72
+ * Unknown names and out-of-range numbers are left untouched.
73
+ */
74
+ export function decodeMermaidEntities(text: string): string {
75
+ return text.replace(
76
+ /#([xX][0-9a-fA-F]+|[0-9]+|[a-zA-Z]+);/g,
77
+ (match, code: string) => {
78
+ if (/^x/i.test(code)) {
79
+ return codePointToChar(parseInt(code.slice(1), 16)) ?? match
80
+ }
81
+ if (/^[0-9]+$/.test(code)) {
82
+ return codePointToChar(parseInt(code, 10)) ?? match
83
+ }
84
+ return NAMED_ENTITIES[code.toLowerCase()] ?? match
85
+ },
86
+ )
87
+ }
88
+
89
+ /**
90
+ * Decode HTML/XML entities (`&quot;`, `&lt;`, `&#35;`, `&#x5B;`) in one label,
91
+ * in a single pass so `&amp;lt;` becomes `&lt;` rather than `<`. Used by the
92
+ * ASCII renderer, which (unlike SVG) has no source-level decode step.
93
+ */
94
+ export function decodeXmlEntitiesInLabel(text: string): string {
95
+ return text.replace(
96
+ /&(#[xX][0-9a-fA-F]+|#[0-9]+|quot|amp|lt|gt|apos);/g,
97
+ (match, code: string) => {
98
+ if (/^#x/i.test(code)) {
99
+ return codePointToChar(parseInt(code.slice(2), 16)) ?? match
100
+ }
101
+ if (code.startsWith('#')) {
102
+ return codePointToChar(parseInt(code.slice(1), 10)) ?? match
103
+ }
104
+ return NAMED_ENTITIES[code] ?? match
105
+ },
42
106
  )
43
107
  }
44
108
 
package/src/theme.ts CHANGED
@@ -560,6 +560,14 @@ function nextTitleId(): string {
560
560
  return `zm-title-${titleIdCounter}`
561
561
  }
562
562
 
563
+ /** Same scheme as `nextTitleId`, for the root `<desc>` (`description`). */
564
+ let descIdCounter = 0
565
+
566
+ function nextDescId(): string {
567
+ descIdCounter += 1
568
+ return `zm-desc-${descIdCounter}`
569
+ }
570
+
563
571
  /**
564
572
  * Test-only: reset the title-id counter so id assertions in tests don't
565
573
  * depend on how many titled diagrams earlier tests in the same file rendered.
@@ -567,6 +575,7 @@ function nextTitleId(): string {
567
575
  */
568
576
  export function __resetSvgTitleIdCounterForTests(): void {
569
577
  titleIdCounter = 0
578
+ descIdCounter = 0
570
579
  }
571
580
 
572
581
  /**
@@ -669,6 +678,11 @@ export function themeStyleDeclarations(
669
678
  * See #239 — forces no root `role` so the link
670
679
  * stays reachable, regardless of `decorative`.
671
680
  * @param styleAttribute - Emit the root `style="…"` attribute. Default true.
681
+ * @param description - Accessible description: a `<desc id="zm-desc-N">`
682
+ * after the title plus `aria-describedby` on the root.
683
+ * Follows the same rules as `title` (dropped when
684
+ * decorative without interactive links). Used for
685
+ * Mermaid's `accDescr`.
672
686
  */
673
687
  export function svgOpenTag(
674
688
  width: number,
@@ -679,6 +693,7 @@ export function svgOpenTag(
679
693
  decorative?: boolean,
680
694
  hasInteractiveLinks?: boolean,
681
695
  styleAttribute: boolean = true,
696
+ description?: string,
682
697
  ): string {
683
698
  const styleAttr = styleAttribute
684
699
  ? ` style="${themeStyleDeclarations(colors, transparent)}"`
@@ -686,23 +701,24 @@ export function svgOpenTag(
686
701
 
687
702
  let a11yAttrs = ''
688
703
  let titleEl = ''
689
- if (hasInteractiveLinks) {
690
- // No root role: role="img" or aria-hidden would hide a real, focusable
691
- // <a href> descendant from assistive tech while leaving it Tab-reachable.
692
- if (title) {
693
- const id = nextTitleId()
694
- a11yAttrs = ` aria-labelledby="${id}"`
695
- titleEl = `\n <title id="${id}">${escapeXml(title)}</title>`
696
- }
697
- } else if (decorative) {
704
+ const named = hasInteractiveLinks || !decorative
705
+ if (!hasInteractiveLinks && decorative) {
698
706
  a11yAttrs = ' aria-hidden="true"'
699
- } else {
707
+ } else if (!hasInteractiveLinks) {
708
+ // No root role with interactive links: role="img" or aria-hidden would
709
+ // hide a real, focusable <a href> descendant from assistive tech while
710
+ // leaving it Tab-reachable.
700
711
  a11yAttrs = ' role="img"'
701
- if (title) {
702
- const id = nextTitleId()
703
- a11yAttrs += ` aria-labelledby="${id}"`
704
- titleEl = `\n <title id="${id}">${escapeXml(title)}</title>`
705
- }
712
+ }
713
+ if (named && title) {
714
+ const id = nextTitleId()
715
+ a11yAttrs += ` aria-labelledby="${id}"`
716
+ titleEl = `\n <title id="${id}">${escapeXml(title)}</title>`
717
+ }
718
+ if (named && description) {
719
+ const id = nextDescId()
720
+ a11yAttrs += ` aria-describedby="${id}"`
721
+ titleEl += `\n <desc id="${id}">${escapeXml(description)}</desc>`
706
722
  }
707
723
 
708
724
  return (