@vintasoftware/pr-review-canvas 0.1.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.
Files changed (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,582 @@
1
+ // @ts-check
2
+ // Mermaid diagrams. A diagram is a `.diagram[data-mermaid]` placeholder holding its source in an
3
+ // attribute; the library is imported only when a page has at least one, runs with
4
+ // `securityLevel: 'strict'`, and its SVG goes through DOMPurify before it reaches the page.
5
+ // Model text is untrusted, so mermaid's own escaping is not the only barrier.
6
+ import DOMPurify from 'dompurify'
7
+ import { followLink } from './deep-link.js'
8
+ import { chevronHtml, esc } from './dom.js'
9
+ import { linkLabel, parseLink } from './links.js'
10
+ import { diagramKind } from './mermaid-fences.js'
11
+
12
+ export { diagramKind }
13
+
14
+ /** The page tokens the diagram theme is built from. Read from the computed style, so both themes work. */
15
+ export const THEME_TOKENS = /** @type {const} */ ([
16
+ '--bg',
17
+ '--panel',
18
+ '--fg',
19
+ '--fg-muted',
20
+ '--line',
21
+ '--accent',
22
+ '--purple',
23
+ '--v1',
24
+ '--v2',
25
+ '--v3',
26
+ '--v4',
27
+ '--v5',
28
+ '--v6',
29
+ ])
30
+
31
+ /** @typedef {Record<(typeof THEME_TOKENS)[number], string>} ThemeColors */
32
+ /** The part of mermaid this module uses, taken from mermaid's own types so a version bump is checked. */
33
+ /** @typedef {Pick<import('mermaid').Mermaid, 'initialize' | 'render'>} MermaidApi */
34
+ /** @typedef {{ load?: () => Promise<MermaidApi>, colors?: ThemeColors }} RenderOptions */
35
+
36
+ /** Colors that keep a diagram readable when a token is missing (the dark theme's values). */
37
+ const FALLBACK_COLORS = {
38
+ '--bg': '#1e1f2b',
39
+ '--panel': '#282a3a',
40
+ '--fg': '#eaf2f1',
41
+ '--fg-muted': '#a4a8b3',
42
+ '--line': '#3a3d4b',
43
+ '--accent': '#91a7ff',
44
+ '--purple': '#c39ac9',
45
+ '--v1': '#ffe066',
46
+ '--v2': '#ffc078',
47
+ '--v3': '#faa2c1',
48
+ '--v4': '#e599f7',
49
+ '--v5': '#b197fc',
50
+ '--v6': '#91a7ff',
51
+ }
52
+
53
+ /**
54
+ * The diagram as a fenced block, which is how a GitHub comment draws it. The fence is longer than
55
+ * the longest run of backticks in the source, so a source that holds a fence of its own stays
56
+ * inside the block when it is pasted.
57
+ * @param {string} source mermaid text
58
+ * @returns {string}
59
+ */
60
+ export function diagramMarkdown(source) {
61
+ const longest = Math.max(0, ...[...source.matchAll(/`+/g)].map(m => m[0].length))
62
+ const fence = '`'.repeat(Math.max(3, longest + 1))
63
+ return `${fence}mermaid\n${source}\n${fence}`
64
+ }
65
+
66
+ /**
67
+ * The placeholder a diagram renders into: a header with the chevron, what kind of drawing it is,
68
+ * and a copy command, then the body the SVG goes in. The source sits in an attribute, never as
69
+ * markup, and so does the sidecar map of node links.
70
+ * @param {string} source mermaid text
71
+ * @param {Record<string, string>} [links] node id → canvas link
72
+ * @returns {string}
73
+ */
74
+ export function diagramPlaceholderHtml(source, links = {}) {
75
+ const kind = diagramKind(source)
76
+ const label = kind === '' ? 'diagram' : `diagram &middot; ${esc(kind)}`
77
+ const map = Object.keys(links).length === 0 ? '' : ` data-links="${esc(JSON.stringify(links))}"`
78
+ return (
79
+ `<div class="diagram" data-mermaid="${esc(source)}"${map}>` +
80
+ `<div class="diagram-h">${chevronHtml('Toggle diagram')}<span class="lbl">${label}</span>` +
81
+ `<button class="cmd" type="button" data-copy="${esc(diagramMarkdown(source))}">copy</button></div>` +
82
+ '<div class="diagram-body"></div></div>'
83
+ )
84
+ }
85
+
86
+ /**
87
+ * The sidecar map a placeholder carries. Anything that is not an object of strings reads as no
88
+ * links at all: the attribute holds model output, which is text like any other.
89
+ * @param {HTMLElement} node
90
+ * @returns {Record<string, string>}
91
+ */
92
+ function linksOf(node) {
93
+ const raw = node.getAttribute('data-links')
94
+ if (raw === null || raw === '') {
95
+ return {}
96
+ }
97
+ try {
98
+ const parsed = JSON.parse(raw)
99
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
100
+ return {}
101
+ }
102
+ return Object.fromEntries(Object.entries(parsed).filter(([, href]) => typeof href === 'string'))
103
+ } catch {
104
+ return {}
105
+ }
106
+ }
107
+
108
+ /** The prefix mermaid puts in front of a node's id, by diagram type. */
109
+ const NODE_PREFIXES = ['flowchart', 'state', 'entity', 'classId']
110
+
111
+ /** @param {string} text */
112
+ function escapeForRegex(text) {
113
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
114
+ }
115
+
116
+ /**
117
+ * The pattern that matches the group id mermaid gives one node: the id of the whole drawing
118
+ * first, then the type's prefix, the source id, and a counter, as in
119
+ * `pr-diagram-3-flowchart-store-1` or `pr-diagram-3-state-active-1`. Naming the drawing keeps a
120
+ * node called `b` apart from a node called `a-state-b`.
121
+ * @param {string} nodeId
122
+ * @param {string} [svgId] the id of the `<svg>` the groups sit in
123
+ * @returns {RegExp}
124
+ */
125
+ export function nodeGroupPattern(nodeId, svgId = '') {
126
+ const head = svgId === '' ? '(?:^|-)' : `^${escapeForRegex(svgId)}-`
127
+ return new RegExp(`${head}(?:${NODE_PREFIXES.join('|')})-${escapeForRegex(nodeId)}-\\d+$`)
128
+ }
129
+
130
+ /**
131
+ * The groups mermaid drew for one node id, inside one diagram body. A sequence participant is
132
+ * found by the `data-id` mermaid writes on it, which it marks with `data-et="participant"`; the
133
+ * other types are found by their group id.
134
+ * @param {ParentNode} body
135
+ * @param {string} nodeId
136
+ * @returns {Element[]}
137
+ */
138
+ export function findNodeGroups(body, nodeId) {
139
+ const svg = body.querySelector('svg')
140
+ const pattern = nodeGroupPattern(nodeId, svg?.getAttribute('id') ?? '')
141
+ return [...body.querySelectorAll('g')].filter(g => {
142
+ const id = g.getAttribute('id')
143
+ const participant = g.getAttribute('data-et') === 'participant' && g.getAttribute('data-id') === nodeId
144
+ return participant || (id !== null && pattern.test(id))
145
+ })
146
+ }
147
+
148
+ /**
149
+ * Makes the drawn nodes of one placeholder clickable, from its sidecar map. The link itself is
150
+ * parsed first, so only the four canvas forms reach the page, and it lands on `data-link`, which
151
+ * the delegated handler reads: mermaid's own `click` directives stay off.
152
+ * @param {HTMLElement} node the `.diagram` element
153
+ * @returns {number} how many groups became links
154
+ */
155
+ export function attachNodeLinks(node) {
156
+ const { body } = partsOf(node)
157
+ if (body === null) {
158
+ return 0
159
+ }
160
+ let attached = 0
161
+ for (const [nodeId, href] of Object.entries(linksOf(node))) {
162
+ const link = parseLink(href)
163
+ if (link === null) {
164
+ continue
165
+ }
166
+ for (const group of findNodeGroups(body, nodeId)) {
167
+ group.setAttribute('data-link', href)
168
+ group.setAttribute('role', 'link')
169
+ group.setAttribute('tabindex', '0')
170
+ group.setAttribute('aria-label', linkLabel(link))
171
+ attached += 1
172
+ }
173
+ }
174
+ return attached
175
+ }
176
+
177
+ /**
178
+ * Follows the node link under an event, if there is one. Click and Enter both arrive here.
179
+ * @param {Event} event
180
+ * @returns {boolean} true when a link was followed
181
+ */
182
+ export function activateNodeLink(event) {
183
+ const target = event.target instanceof Element ? event.target.closest('.diagram-body [data-link]') : null
184
+ const href = target?.getAttribute('data-link') ?? null
185
+ if (href === null) {
186
+ return false
187
+ }
188
+ event.preventDefault()
189
+ return followLink(href)
190
+ }
191
+
192
+ /**
193
+ * The parts of one placeholder. An empty body means the diagram still needs drawing, which is what
194
+ * a closed one is left with: it is drawn when it is opened rather than while nobody can see it.
195
+ * @param {HTMLElement} node
196
+ */
197
+ function partsOf(node) {
198
+ const chevron = node.querySelector('.diagram-h .chev')
199
+ const body = node.querySelector('.diagram-body')
200
+ return {
201
+ chevron: chevron instanceof HTMLElement ? chevron : null,
202
+ body: body instanceof HTMLElement ? body : null,
203
+ open: chevron === null || chevron.getAttribute('aria-expanded') !== 'false',
204
+ }
205
+ }
206
+
207
+ /** What the muted line says when a placeholder shows its source instead of a drawing. */
208
+ export const FALLBACK_NOTE = 'diagram could not be rendered'
209
+ export const DIRECTIVE_NOTE = 'diagram not drawn: it sets mermaid options'
210
+
211
+ /**
212
+ * What a placeholder shows when mermaid cannot draw it: the source, and a line saying why.
213
+ * @param {string} source
214
+ * @param {string} [note]
215
+ * @returns {string}
216
+ */
217
+ export function diagramFallbackHtml(source, note = FALLBACK_NOTE) {
218
+ return `<pre class="diagram-src"><code>${esc(source)}</code></pre><p class="muted small">${esc(note)}</p>`
219
+ }
220
+
221
+ /**
222
+ * True when the source sets mermaid options, through a `%%{...}%%` directive or through YAML front
223
+ * matter at the top. Mermaid keeps `securityLevel` out of their reach but lets them set `themeCSS`,
224
+ * which puts CSS of the author's choosing inside the drawing, and `htmlLabels`, which draws labels
225
+ * the SVG sanitizer then removes. The source comes from a model or from a pull request written by
226
+ * anyone, and the page supplies the theme, so a diagram that sets options is not drawn.
227
+ * @param {string} source
228
+ * @returns {boolean}
229
+ */
230
+ export function setsOptions(source) {
231
+ return source.includes('%%{') || /^\s*---\s*[\n\r]/.test(source)
232
+ }
233
+
234
+ /**
235
+ * The page's colors, resolved. The tokens use `light-dark()`, which a custom property keeps
236
+ * unresolved, so each one is read back through a probe element's `color`. A token the page never
237
+ * declares gets its fallback: an undeclared property makes the probe's `color` invalid, and the
238
+ * probe would then report the color it inherits.
239
+ * @param {Document} [doc]
240
+ * @returns {ThemeColors}
241
+ */
242
+ export function readThemeColors(doc = document) {
243
+ const view = doc.defaultView
244
+ const root = view === null || view === undefined ? null : view.getComputedStyle(doc.documentElement)
245
+ const probe = doc.createElement('span')
246
+ probe.style.display = 'none'
247
+ doc.body.append(probe)
248
+ try {
249
+ return /** @type {ThemeColors} */ (
250
+ Object.fromEntries(
251
+ THEME_TOKENS.map(token => {
252
+ if ((root?.getPropertyValue(token) ?? '').trim() === '') {
253
+ return [token, FALLBACK_COLORS[token]]
254
+ }
255
+ probe.style.color = `var(${token})`
256
+ const value = view?.getComputedStyle(probe).color ?? ''
257
+ return [token, value === '' ? FALLBACK_COLORS[token] : value]
258
+ })
259
+ )
260
+ )
261
+ } finally {
262
+ probe.remove()
263
+ }
264
+ }
265
+
266
+ /**
267
+ * The mermaid config for one theme. `base` plus the page tokens, so a diagram looks like the page
268
+ * it sits in instead of like mermaid.
269
+ * @param {ThemeColors} c
270
+ */
271
+ export function mermaidConfig(c) {
272
+ // `useMaxWidth: false` keeps the drawing at its own size, so a wide one scrolls inside its card
273
+ // at a readable scale. It is per diagram type, so each type the prompt recommends says it.
274
+ const own = { useMaxWidth: false }
275
+ return {
276
+ startOnLoad: false,
277
+ securityLevel: /** @type {const} */ ('strict'),
278
+ // Mermaid draws its own error diagram into a temporary element and leaves it in the page;
279
+ // with this on it cleans up and throws, and the placeholder shows the source instead.
280
+ suppressErrorRendering: true,
281
+ theme: /** @type {const} */ ('base'),
282
+ // SVG text labels: an HTML label would be stripped by the SVG sanitizer.
283
+ htmlLabels: false,
284
+ flowchart: { htmlLabels: false, ...own },
285
+ sequence: own,
286
+ state: own,
287
+ er: own,
288
+ themeVariables: {
289
+ background: c['--bg'],
290
+ mainBkg: c['--panel'],
291
+ primaryColor: c['--panel'],
292
+ primaryTextColor: c['--fg'],
293
+ primaryBorderColor: c['--fg'],
294
+ secondaryColor: c['--bg'],
295
+ secondaryBorderColor: c['--line'],
296
+ tertiaryColor: c['--bg'],
297
+ tertiaryBorderColor: c['--line'],
298
+ textColor: c['--fg'],
299
+ nodeTextColor: c['--fg'],
300
+ nodeBorder: c['--fg'],
301
+ lineColor: c['--fg-muted'],
302
+ clusterBkg: c['--bg'],
303
+ clusterBorder: c['--line'],
304
+ titleColor: c['--fg'],
305
+ edgeLabelBackground: c['--bg'],
306
+ labelBoxBkgColor: c['--panel'],
307
+ labelBoxBorderColor: c['--line'],
308
+ labelTextColor: c['--fg'],
309
+ loopTextColor: c['--fg'],
310
+ noteBkgColor: c['--bg'],
311
+ noteTextColor: c['--fg-muted'],
312
+ noteBorderColor: c['--purple'],
313
+ actorBkg: c['--panel'],
314
+ actorBorder: c['--fg'],
315
+ actorTextColor: c['--fg'],
316
+ actorLineColor: c['--line'],
317
+ signalColor: c['--fg-muted'],
318
+ signalTextColor: c['--fg'],
319
+ activationBkgColor: c['--panel'],
320
+ activationBorderColor: c['--accent'],
321
+ cScale0: c['--v1'],
322
+ cScale1: c['--v2'],
323
+ cScale2: c['--v3'],
324
+ cScale3: c['--v4'],
325
+ cScale4: c['--v5'],
326
+ cScale5: c['--v6'],
327
+ },
328
+ }
329
+ }
330
+
331
+ /** The library is fetched once per page, the first time a diagram needs it. */
332
+ /** @type {Promise<MermaidApi> | null} */
333
+ let modulePromise = null
334
+
335
+ /**
336
+ * The default loader: the import map points `mermaid` at the vendored ESM build.
337
+ * @returns {Promise<MermaidApi>}
338
+ */
339
+ async function importMermaid() {
340
+ const module = await import('mermaid')
341
+ return module.default
342
+ }
343
+
344
+ /** Forgets the loaded library. Tests call it between cases; the page never does. */
345
+ export function resetMermaid() {
346
+ modulePromise = null
347
+ nextId = 0
348
+ }
349
+
350
+ let nextId = 0
351
+
352
+ /** Each call to `drawNodes` takes the next number and claims the nodes it draws with it. */
353
+ let nextPass = 0
354
+
355
+ /**
356
+ * Sanitizes one rendered SVG. `svg` and `svgFilters` cover the shapes mermaid emits; scripts,
357
+ * event handlers, `foreignObject`, and `javascript:` targets do not survive. The `<style>` mermaid
358
+ * writes its theme into stays, which is why a source that sets `themeCSS` is refused earlier.
359
+ * @param {string} svg
360
+ * @returns {string}
361
+ */
362
+ export function sanitizeSvg(svg) {
363
+ return DOMPurify.sanitize(svg, {
364
+ USE_PROFILES: { svg: true, svgFilters: true },
365
+ ADD_ATTR: ['viewBox', 'preserveAspectRatio', 'marker-end', 'marker-start', 'dominant-baseline'],
366
+ })
367
+ }
368
+
369
+ /**
370
+ * Draws every placeholder under `root`. Nothing is imported when there is none.
371
+ * @param {ParentNode} root
372
+ * @param {RenderOptions & { cancelled?: () => boolean }} [opts]
373
+ * @returns {Promise<{ rendered: number, failed: number }>}
374
+ */
375
+ export async function renderDiagrams(root, opts = {}) {
376
+ return drawNodes(collectNodes(root), opts)
377
+ }
378
+
379
+ /**
380
+ * The placeholders under `root`, and `root` itself when it is one. Opening a single diagram draws
381
+ * that one node, so the collector has to accept it as well as its container.
382
+ * @param {ParentNode} root
383
+ * @returns {HTMLElement[]}
384
+ */
385
+ function collectNodes(root) {
386
+ const found = /** @type {HTMLElement[]} */ ([...root.querySelectorAll('.diagram[data-mermaid]')])
387
+ return root instanceof HTMLElement && root.matches('.diagram[data-mermaid]') ? [root, ...found] : found
388
+ }
389
+
390
+ /**
391
+ * Draws the given placeholders. Nothing is imported when the list is empty, and a closed diagram
392
+ * is left for the moment it is opened.
393
+ * @param {readonly HTMLElement[]} nodes
394
+ * @param {RenderOptions & { cancelled?: () => boolean }} opts
395
+ * @returns {Promise<{ rendered: number, failed: number }>}
396
+ */
397
+ async function drawNodes(nodes, opts) {
398
+ nextPass += 1
399
+ const pass = String(nextPass)
400
+ // Every node is claimed before anything is awaited, so the pass that started last owns them all
401
+ // and a pass that resumes later can no longer take one back.
402
+ /** @type {HTMLElement[]} */
403
+ const open = []
404
+ for (const node of nodes) {
405
+ node.setAttribute('data-pass', pass)
406
+ const parts = partsOf(node)
407
+ if (parts.open) {
408
+ open.push(node)
409
+ continue
410
+ }
411
+ if (parts.body !== null) {
412
+ parts.body.innerHTML = ''
413
+ }
414
+ }
415
+ if (open.length === 0) {
416
+ return { rendered: 0, failed: 0 }
417
+ }
418
+ const cancelled = opts.cancelled ?? (() => false)
419
+ const load = opts.load ?? importMermaid
420
+ let mermaid
421
+ try {
422
+ modulePromise ??= load()
423
+ mermaid = await modulePromise
424
+ } catch {
425
+ // A later render tries the import again; the page keeps working without the library.
426
+ modulePromise = null
427
+ if (cancelled()) {
428
+ return { rendered: 0, failed: 0 }
429
+ }
430
+ let shown = 0
431
+ for (const node of open) {
432
+ shown += write(node, pass, diagramFallbackHtml(node.getAttribute('data-mermaid') ?? '')) ? 1 : 0
433
+ }
434
+ return { rendered: 0, failed: shown }
435
+ }
436
+ if (cancelled()) {
437
+ return { rendered: 0, failed: 0 }
438
+ }
439
+ mermaid.initialize(mermaidConfig(opts.colors ?? readThemeColors()))
440
+ let rendered = 0
441
+ let failed = 0
442
+ for (const node of open) {
443
+ if (node.getAttribute('data-pass') !== pass) {
444
+ continue
445
+ }
446
+ const source = node.getAttribute('data-mermaid') ?? ''
447
+ if (setsOptions(source)) {
448
+ failed += write(node, pass, diagramFallbackHtml(source, DIRECTIVE_NOTE)) ? 1 : 0
449
+ continue
450
+ }
451
+ nextId += 1
452
+ try {
453
+ const result = await mermaid.render(`pr-diagram-${nextId}`, source)
454
+ if (cancelled()) {
455
+ break
456
+ }
457
+ rendered += write(node, pass, sanitizeSvg(result.svg)) ? 1 : 0
458
+ } catch {
459
+ if (cancelled()) {
460
+ break
461
+ }
462
+ failed += write(node, pass, diagramFallbackHtml(source)) ? 1 : 0
463
+ }
464
+ }
465
+ return { rendered, failed }
466
+ }
467
+
468
+ /**
469
+ * Puts a drawing, or the source it could not draw, in the placeholder's body. The header stays.
470
+ * A pass writes only while it still owns the node: a theme flip or an opened diagram starts a
471
+ * newer pass, and the drawing the older one was working on is the wrong one by then. A node that
472
+ * was closed while the pass ran is left empty, so opening it draws it in the theme on screen.
473
+ * @param {HTMLElement} node
474
+ * @param {string} pass the writer's pass number
475
+ * @param {string} html already sanitized or escaped
476
+ * @returns {boolean} true when the html reached the page
477
+ */
478
+ function write(node, pass, html) {
479
+ if (node.getAttribute('data-pass') !== pass) {
480
+ return false
481
+ }
482
+ const { body, open } = partsOf(node)
483
+ if (body === null) {
484
+ return false
485
+ }
486
+ if (open) {
487
+ body.innerHTML = html
488
+ // Every drawing is written here, so this is where a redrawn diagram gets its links back.
489
+ attachNodeLinks(node)
490
+ return true
491
+ }
492
+ body.innerHTML = ''
493
+ return false
494
+ }
495
+
496
+ /**
497
+ * Runs `onChange` whenever the theme or the skin flips, so the diagrams can be drawn again in the
498
+ * new colors. Both attributes change the tokens a diagram is built from.
499
+ * @param {() => void} onChange
500
+ * @param {HTMLElement} [target] usually <html>
501
+ * @returns {{ stop: () => void }}
502
+ */
503
+ export function observeTheme(onChange, target = document.documentElement) {
504
+ if (typeof MutationObserver === 'undefined') {
505
+ return { stop: () => {} }
506
+ }
507
+ const observer = new MutationObserver(() => onChange())
508
+ observer.observe(target, { attributes: true, attributeFilter: ['data-theme', 'data-skin'] })
509
+ return { stop: () => observer.disconnect() }
510
+ }
511
+
512
+ /**
513
+ * Opens or closes one diagram. One that holds no drawing is drawn now, whether it was closed
514
+ * before the page got to it or emptied by a theme flip, so a reader always opens a diagram in the
515
+ * theme on screen. One that already holds its drawing is shown again without redrawing.
516
+ * @param {HTMLElement} node the `.diagram` element
517
+ * @param {RenderOptions & { cancelled?: () => boolean }} [opts]
518
+ * @returns {Promise<void>}
519
+ */
520
+ export async function toggleDiagram(node, opts = {}) {
521
+ const { chevron, body } = partsOf(node)
522
+ if (chevron === null || body === null) {
523
+ return
524
+ }
525
+ const opening = chevron.getAttribute('aria-expanded') === 'false'
526
+ chevron.setAttribute('aria-expanded', opening ? 'true' : 'false')
527
+ body.hidden = !opening
528
+ if (opening && body.innerHTML === '') {
529
+ await drawNodes([node], opts)
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Draws the diagrams of a screen, keeps them in the page's theme, and lets a reader close one.
535
+ * `stop` ends the watch, the click handler, and the pass that is running, so a screen that goes
536
+ * away writes nothing more.
537
+ * @param {ParentNode} root
538
+ * @param {RenderOptions} [opts]
539
+ */
540
+ export function initDiagrams(root, opts = {}) {
541
+ let stopped = false
542
+ const cancelled = () => stopped
543
+ const pass = { ...opts, cancelled }
544
+ void renderDiagrams(root, pass)
545
+ const watch = observeTheme(() => {
546
+ void renderDiagrams(root, pass)
547
+ })
548
+ const clicks = new AbortController()
549
+ if (root instanceof HTMLElement || root instanceof Document) {
550
+ // One listener for the screen: a redrawn diagram brings new elements, never a new handler.
551
+ root.addEventListener(
552
+ 'click',
553
+ event => {
554
+ if (activateNodeLink(event)) {
555
+ return
556
+ }
557
+ const chevron = event.target instanceof Element ? event.target.closest('.diagram > .diagram-h > .chev') : null
558
+ const node = chevron?.closest('.diagram')
559
+ if (node instanceof HTMLElement) {
560
+ void toggleDiagram(node, pass)
561
+ }
562
+ },
563
+ { signal: clicks.signal }
564
+ )
565
+ root.addEventListener(
566
+ 'keydown',
567
+ event => {
568
+ if (event instanceof KeyboardEvent && event.key === 'Enter') {
569
+ activateNodeLink(event)
570
+ }
571
+ },
572
+ { signal: clicks.signal }
573
+ )
574
+ }
575
+ return {
576
+ stop: () => {
577
+ stopped = true
578
+ watch.stop()
579
+ clicks.abort()
580
+ },
581
+ }
582
+ }