@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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- 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 · ${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
|
+
}
|