create-pathfinder 4.2.0 → 4.3.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/CLAUDE.md +2 -0
- package/package.json +1 -1
- package/skills/learn-codebase/SKILL.md +188 -17
- package/skills/learn-feature/SKILL.md +136 -15
- package/skills/map-system/SKILL.md +293 -0
- package/skills/render-artifact/SKILL.md +187 -0
- package/skills/render-artifact/engine/bin/render.mjs +225 -0
- package/skills/render-artifact/engine/deliver.mjs +197 -0
- package/skills/render-artifact/engine/doctor.mjs +96 -0
- package/skills/render-artifact/engine/examples/diagram.json +223 -0
- package/skills/render-artifact/engine/examples/lesson.json +242 -0
- package/skills/render-artifact/engine/references/determinism.md +71 -0
- package/skills/render-artifact/engine/references/specification.md +149 -0
- package/skills/render-artifact/engine/references/validation.md +268 -0
- package/skills/render-artifact/engine/render/behavior.mjs +128 -0
- package/skills/render-artifact/engine/render/diagram.mjs +342 -0
- package/skills/render-artifact/engine/render/escape.mjs +34 -0
- package/skills/render-artifact/engine/render/graph/behavior.mjs +394 -0
- package/skills/render-artifact/engine/render/graph/draw.mjs +204 -0
- package/skills/render-artifact/engine/render/graph/interaction.mjs +174 -0
- package/skills/render-artifact/engine/render/graph/layout.mjs +698 -0
- package/skills/render-artifact/engine/render/graph/style.mjs +200 -0
- package/skills/render-artifact/engine/render/graph/width.mjs +204 -0
- package/skills/render-artifact/engine/render/index.mjs +50 -0
- package/skills/render-artifact/engine/render/lesson.mjs +294 -0
- package/skills/render-artifact/engine/render/shell.mjs +275 -0
- package/skills/render-artifact/engine/render/theme.mjs +592 -0
- package/skills/render-artifact/engine/schemas/common.schema.json +101 -0
- package/skills/render-artifact/engine/schemas/diagram.schema.json +176 -0
- package/skills/render-artifact/engine/schemas/lesson.schema.json +210 -0
- package/skills/render-artifact/engine/validate/composition.mjs +395 -0
- package/skills/render-artifact/engine/validate/diagnostics.mjs +83 -0
- package/skills/render-artifact/engine/validate/diagram-parts.mjs +68 -0
- package/skills/render-artifact/engine/validate/evidence.mjs +302 -0
- package/skills/render-artifact/engine/validate/index.mjs +132 -0
- package/skills/render-artifact/engine/validate/jsonschema.mjs +312 -0
- package/skills/render-artifact/engine/validate/structural.mjs +241 -0
- package/skills/render-artifact/engine/verification.mjs +76 -0
- package/skills/render-artifact/engine/version.mjs +24 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `diagram` renderer.
|
|
3
|
+
*
|
|
4
|
+
* A diagram artifact is two readings of one set of facts. The canvas is the
|
|
5
|
+
* picture; everything below it is the same graph as text — every node with its
|
|
6
|
+
* role and description, every relationship, every authored walk, every view,
|
|
7
|
+
* and every citation. Both are emitted into the document, which is what lets
|
|
8
|
+
* the artifact be read with no scripting at all, and what the interactions in
|
|
9
|
+
* a later ticket will attach to rather than generate.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here decides where anything goes. `layout.mjs` owns geometry,
|
|
12
|
+
* `draw.mjs` owns the markup for it, and this module owns the page around
|
|
13
|
+
* them — which sections exist, what they are called, and in what order. Every
|
|
14
|
+
* word naming a section is renderer interface language; every word inside one
|
|
15
|
+
* came from the specification.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { esc, domId } from "./escape.mjs";
|
|
19
|
+
import { renderShell, renderNav } from "./shell.mjs";
|
|
20
|
+
import { layoutGraph } from "./graph/layout.mjs";
|
|
21
|
+
import { drawGraph } from "./graph/draw.mjs";
|
|
22
|
+
import { GRAPH_CSS } from "./graph/style.mjs";
|
|
23
|
+
import { graphBehavior } from "./graph/behavior.mjs";
|
|
24
|
+
import { interactionModel, serializeModel } from "./graph/interaction.mjs";
|
|
25
|
+
|
|
26
|
+
/** Renderer-owned interface language. The producer supplies none of this. */
|
|
27
|
+
const UI = Object.freeze({
|
|
28
|
+
eyebrow: "Pathfinder diagram",
|
|
29
|
+
navLabel: "In this diagram",
|
|
30
|
+
components: "Components",
|
|
31
|
+
ungrouped: "Not in a boundary",
|
|
32
|
+
relationships: "Relationships",
|
|
33
|
+
paths: "Paths",
|
|
34
|
+
views: "Views",
|
|
35
|
+
evidence: "Evidence",
|
|
36
|
+
walks: "Walks",
|
|
37
|
+
focuses: "Focuses on",
|
|
38
|
+
canvasLabel: "Diagram canvas. Arrow keys pan, plus and minus zoom, 0 fits.",
|
|
39
|
+
toolbar: "Diagram controls",
|
|
40
|
+
nothing: "Nothing selected. Choose a component to focus it.",
|
|
41
|
+
zoomIn: "Zoom in",
|
|
42
|
+
zoomOut: "Zoom out",
|
|
43
|
+
fit: "Fit",
|
|
44
|
+
reset: "Reset",
|
|
45
|
+
upstream: "Upstream",
|
|
46
|
+
downstream: "Downstream",
|
|
47
|
+
details: "Go to details",
|
|
48
|
+
clear: "Clear",
|
|
49
|
+
focusOne: "Focus",
|
|
50
|
+
highlight: "Highlight",
|
|
51
|
+
relations: {
|
|
52
|
+
calls: "calls",
|
|
53
|
+
reads: "reads",
|
|
54
|
+
writes: "writes",
|
|
55
|
+
publishes: "publishes to",
|
|
56
|
+
consumes: "consumes from",
|
|
57
|
+
depends_on: "depends on",
|
|
58
|
+
transitions_to: "becomes",
|
|
59
|
+
triggers: "triggers",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} spec a `diagram` specification
|
|
65
|
+
* @param {object} [verification] an attestation, passed through to the shell
|
|
66
|
+
* @returns {string} a complete HTML document
|
|
67
|
+
*/
|
|
68
|
+
export function renderDiagram(spec, verification) {
|
|
69
|
+
const { diagram } = spec;
|
|
70
|
+
const layout = layoutGraph(diagram);
|
|
71
|
+
|
|
72
|
+
const groups = diagram.groups ?? [];
|
|
73
|
+
const views = diagram.views ?? [];
|
|
74
|
+
const nav = renderNav([
|
|
75
|
+
...groups.map((group) => ({ id: domId("s", "g", group.id), label: group.label })),
|
|
76
|
+
...views.map((view) => ({ id: domId("s", "v", view.id), label: view.label })),
|
|
77
|
+
], UI.navLabel);
|
|
78
|
+
|
|
79
|
+
const body = [
|
|
80
|
+
renderLead(spec),
|
|
81
|
+
renderTools(),
|
|
82
|
+
`<div class="pf-canvas" data-pf-canvas tabindex="0" role="group" ` +
|
|
83
|
+
`aria-label="${esc(UI.canvasLabel)}">${drawGraph(diagram, layout)}</div>`,
|
|
84
|
+
renderComponents(diagram),
|
|
85
|
+
renderRelationships(diagram),
|
|
86
|
+
renderPaths(diagram),
|
|
87
|
+
renderViews(diagram),
|
|
88
|
+
].filter((part) => part !== "").join("\n");
|
|
89
|
+
|
|
90
|
+
return renderShell({
|
|
91
|
+
lang: spec.artifact.locale ?? "en",
|
|
92
|
+
title: spec.artifact.title,
|
|
93
|
+
eyebrow: UI.eyebrow,
|
|
94
|
+
description: spec.artifact.summary ?? spec.artifact.subtitle,
|
|
95
|
+
style: GRAPH_CSS,
|
|
96
|
+
nav,
|
|
97
|
+
body,
|
|
98
|
+
source: spec.source,
|
|
99
|
+
// Handed over, never interpreted here. Which of the three things an
|
|
100
|
+
// artifact may say about its own provenance is the shell's decision and the
|
|
101
|
+
// shell's wording; this renderer's only part in it is reporting what the
|
|
102
|
+
// specification declared.
|
|
103
|
+
provenance: spec.provenance,
|
|
104
|
+
// The reading interactions run on this index and on nothing else. It is
|
|
105
|
+
// derived from the specification, carries no summary, detail or citation,
|
|
106
|
+
// and is therefore incapable of being the only place a fact lives.
|
|
107
|
+
behavior: graphBehavior(serializeModel(interactionModel(diagram))),
|
|
108
|
+
verification,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The controls, and the line that says what is selected.
|
|
114
|
+
*
|
|
115
|
+
* `hidden` in the delivered document and revealed by the script, which is the
|
|
116
|
+
* same bargain the shell's theme toggle strikes: a control that cannot work is
|
|
117
|
+
* not offered. Without scripting a reader meets no dead buttons, and loses
|
|
118
|
+
* nothing they could have read — every fact these controls navigate to is
|
|
119
|
+
* already written out below the canvas.
|
|
120
|
+
*/
|
|
121
|
+
function renderTools() {
|
|
122
|
+
const button = (action, text, extra = "") =>
|
|
123
|
+
`<button type="button" class="pf-tool" data-pf-act="${esc(action)}"${extra}>` +
|
|
124
|
+
`${esc(text)}</button>`;
|
|
125
|
+
|
|
126
|
+
return [
|
|
127
|
+
'<div class="pf-graph-tools" data-pf-controls hidden>',
|
|
128
|
+
`<div class="pf-toolbar" role="toolbar" aria-label="${esc(UI.toolbar)}">`,
|
|
129
|
+
button("zoom-out", UI.zoomOut),
|
|
130
|
+
button("zoom-in", UI.zoomIn),
|
|
131
|
+
button("fit", UI.fit),
|
|
132
|
+
button("reset", UI.reset),
|
|
133
|
+
'<span class="pf-tool-sep" aria-hidden="true"></span>',
|
|
134
|
+
button("upstream", UI.upstream, " disabled"),
|
|
135
|
+
button("downstream", UI.downstream, " disabled"),
|
|
136
|
+
button("details", UI.details, " disabled"),
|
|
137
|
+
button("clear", UI.clear, " disabled"),
|
|
138
|
+
"</div>",
|
|
139
|
+
`<p class="pf-graph-status" data-pf-status aria-live="polite">` +
|
|
140
|
+
`${esc(UI.nothing)}</p>`,
|
|
141
|
+
"</div>",
|
|
142
|
+
].join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderLead(spec) {
|
|
146
|
+
const { artifact } = spec;
|
|
147
|
+
const out = ['<div class="pf-lead">', `<h1>${esc(artifact.title)}</h1>`];
|
|
148
|
+
if (artifact.subtitle) {
|
|
149
|
+
out.push(`<p class="pf-lead-sub">${esc(artifact.subtitle)}</p>`);
|
|
150
|
+
}
|
|
151
|
+
if (artifact.summary) {
|
|
152
|
+
out.push(`<div class="pf-lead-summary"><p>${esc(artifact.summary)}</p></div>`);
|
|
153
|
+
}
|
|
154
|
+
out.push("</div>");
|
|
155
|
+
return out.join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The nodes, under the boundary each belongs to.
|
|
160
|
+
*
|
|
161
|
+
* Groups come first in specification order, then whatever belongs to no group.
|
|
162
|
+
* A node appears exactly once, so the list is the graph's node set rather than
|
|
163
|
+
* a view of it.
|
|
164
|
+
*/
|
|
165
|
+
function renderComponents(diagram) {
|
|
166
|
+
const groups = diagram.groups ?? [];
|
|
167
|
+
const blocks = [];
|
|
168
|
+
|
|
169
|
+
for (const group of groups) {
|
|
170
|
+
const members = diagram.nodes.filter((node) => node.group === group.id);
|
|
171
|
+
|
|
172
|
+
// A group with no members of its own is still written out when it has
|
|
173
|
+
// something to say. A parent group holds other groups rather than nodes,
|
|
174
|
+
// so skipping it for having no members dropped its summary and — worse —
|
|
175
|
+
// its citations: the evidence layer requires a claim-bearing group to cite
|
|
176
|
+
// its claim, and the reader was then never shown either. A label-only
|
|
177
|
+
// group with no members has nothing to write and is represented by its
|
|
178
|
+
// boundary on the canvas.
|
|
179
|
+
const hasSomethingToSay = group.summary !== undefined
|
|
180
|
+
|| (group.evidence ?? []).length > 0;
|
|
181
|
+
if (members.length === 0 && !hasSomethingToSay) continue;
|
|
182
|
+
|
|
183
|
+
blocks.push(section(domId("s", "g", group.id), group.label, [
|
|
184
|
+
group.summary ? `<p class="pf-module-summary">${esc(group.summary)}</p>` : "",
|
|
185
|
+
renderEvidence(group.evidence),
|
|
186
|
+
members.map(renderNode).join("\n"),
|
|
187
|
+
].filter(Boolean).join("\n")));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const loose = diagram.nodes.filter((node) =>
|
|
191
|
+
node.group === undefined || !groups.some((group) => group.id === node.group));
|
|
192
|
+
if (loose.length > 0) {
|
|
193
|
+
blocks.push(section(domId("s", "ungrouped"), UI.ungrouped,
|
|
194
|
+
loose.map(renderNode).join("\n")));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return blocks.length === 0 ? "" : heading(UI.components) + blocks.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renderNode(node) {
|
|
201
|
+
const out = [
|
|
202
|
+
`<div class="pf-card" id="${esc(domId("s", "n", node.id))}" ` +
|
|
203
|
+
`data-pf-entry="node" data-pf-for="${esc(node.id)}">`,
|
|
204
|
+
`<h4 class="pf-section-title">${esc(node.label)}` +
|
|
205
|
+
`<span class="pf-legend-role">${esc(node.role)}</span>` +
|
|
206
|
+
// Named for the component rather than "Focus" twenty times over: a
|
|
207
|
+
// reader listing the page's buttons has to be able to tell them apart.
|
|
208
|
+
`<span class="pf-pick" data-pf-controls hidden>` +
|
|
209
|
+
`<button type="button" class="pf-tool" data-pf-pick="${esc(node.id)}" ` +
|
|
210
|
+
`aria-label="${esc(`${UI.focusOne} ${node.label}`)}">${esc(UI.focusOne)}` +
|
|
211
|
+
`</button></span></h4>`,
|
|
212
|
+
];
|
|
213
|
+
if (node.summary) out.push(`<p>${esc(node.summary)}</p>`);
|
|
214
|
+
for (const paragraph of node.detail ?? []) out.push(`<p>${esc(paragraph)}</p>`);
|
|
215
|
+
const evidence = renderEvidence(node.evidence);
|
|
216
|
+
if (evidence) out.push(evidence);
|
|
217
|
+
out.push("</div>");
|
|
218
|
+
return out.join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderRelationships(diagram) {
|
|
222
|
+
if (diagram.edges.length === 0) return "";
|
|
223
|
+
const labelOf = new Map(diagram.nodes.map((node) => [node.id, node.label]));
|
|
224
|
+
|
|
225
|
+
const rows = diagram.edges.map((edge) => {
|
|
226
|
+
const out = [
|
|
227
|
+
`<li id="${esc(domId("s", "e", edge.id))}" data-pf-entry="edge" ` +
|
|
228
|
+
`data-pf-for="${esc(edge.id)}">`,
|
|
229
|
+
`<span class="pf-step-title">${esc(labelOf.get(edge.from) ?? edge.from)}</span> `,
|
|
230
|
+
`<span class="pf-relation">${esc(UI.relations[edge.relation])}</span> `,
|
|
231
|
+
`<span class="pf-step-title">${esc(labelOf.get(edge.to) ?? edge.to)}</span>`,
|
|
232
|
+
];
|
|
233
|
+
if (edge.label) out.push(`<p class="pf-step-detail">${esc(edge.label)}</p>`);
|
|
234
|
+
const evidence = renderEvidence(edge.evidence);
|
|
235
|
+
if (evidence) out.push(evidence);
|
|
236
|
+
out.push("</li>");
|
|
237
|
+
return out.join("");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return heading(UI.relationships)
|
|
241
|
+
+ section(domId("s", "relationships"), "",
|
|
242
|
+
`<ul class="pf-legend">${rows.join("\n")}</ul>`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The authored walks, expanded from the edge ids they name.
|
|
247
|
+
*
|
|
248
|
+
* A path names edges rather than nodes so the claim is exact, and this is where
|
|
249
|
+
* that pays off for a reader: the walk below is the edges the producer chose,
|
|
250
|
+
* in order, even where two of them join the same pair of things.
|
|
251
|
+
*/
|
|
252
|
+
function renderPaths(diagram) {
|
|
253
|
+
const paths = diagram.paths ?? [];
|
|
254
|
+
if (paths.length === 0) return "";
|
|
255
|
+
|
|
256
|
+
const labelOf = new Map(diagram.nodes.map((node) => [node.id, node.label]));
|
|
257
|
+
const edgeById = new Map(diagram.edges.map((edge) => [edge.id, edge]));
|
|
258
|
+
|
|
259
|
+
const blocks = paths.map((path) => {
|
|
260
|
+
const steps = path.edges.map((id) => {
|
|
261
|
+
const edge = edgeById.get(id);
|
|
262
|
+
if (edge === undefined) return "";
|
|
263
|
+
return `<li>${esc(labelOf.get(edge.from) ?? edge.from)} ` +
|
|
264
|
+
`<span class="pf-relation">${esc(UI.relations[edge.relation])}</span> ` +
|
|
265
|
+
`${esc(labelOf.get(edge.to) ?? edge.to)}` +
|
|
266
|
+
(edge.label ? ` — ${esc(edge.label)}` : "") + "</li>";
|
|
267
|
+
}).filter(Boolean);
|
|
268
|
+
|
|
269
|
+
return section(domId("s", "p", path.id), path.label, [
|
|
270
|
+
path.note ? `<p class="pf-module-summary">${esc(path.note)}</p>` : "",
|
|
271
|
+
`<div class="pf-kicker">${esc(UI.walks)}</div>`,
|
|
272
|
+
`<ol class="pf-walk">${steps.join("\n")}</ol>`,
|
|
273
|
+
renderEvidence(path.evidence),
|
|
274
|
+
`<div class="pf-pick" data-pf-controls hidden>` +
|
|
275
|
+
`<button type="button" class="pf-tool" data-pf-act="path" ` +
|
|
276
|
+
`data-pf-path="${esc(path.id)}" ` +
|
|
277
|
+
`aria-label="${esc(`${UI.highlight} ${path.label}`)}">` +
|
|
278
|
+
`${esc(UI.highlight)}</button></div>`,
|
|
279
|
+
].filter(Boolean).join("\n"), `data-pf-entry="path" data-pf-for="${esc(path.id)}"`);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return heading(UI.paths) + blocks.join("\n");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderViews(diagram) {
|
|
286
|
+
const views = diagram.views ?? [];
|
|
287
|
+
if (views.length === 0) return "";
|
|
288
|
+
const labelOf = new Map(diagram.nodes.map((node) => [node.id, node.label]));
|
|
289
|
+
|
|
290
|
+
const blocks = views.map((view) => section(domId("s", "v", view.id), view.label, [
|
|
291
|
+
view.note ? `<p class="pf-module-summary">${esc(view.note)}</p>` : "",
|
|
292
|
+
`<div class="pf-kicker">${esc(UI.focuses)}</div>`,
|
|
293
|
+
`<ul class="pf-legend">${view.focus.map((id) =>
|
|
294
|
+
`<li>${esc(labelOf.get(id) ?? id)}</li>`).join("")}</ul>`,
|
|
295
|
+
renderEvidence(view.evidence),
|
|
296
|
+
].filter(Boolean).join("\n")));
|
|
297
|
+
|
|
298
|
+
return heading(UI.views) + blocks.join("\n");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function heading(text) {
|
|
302
|
+
return `<h2 class="pf-module-title">${esc(text)}</h2>`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function section(id, title, inner, attributes = "") {
|
|
306
|
+
const out = [
|
|
307
|
+
`<section class="pf-section" id="${esc(id)}"` +
|
|
308
|
+
`${attributes ? ` ${attributes}` : ""}>`,
|
|
309
|
+
];
|
|
310
|
+
if (title) out.push(`<h3 class="pf-section-title">${esc(title)}</h3>`);
|
|
311
|
+
out.push('<div class="pf-card">', inner, "</div>", "</section>");
|
|
312
|
+
return out.join("\n");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Citations, in the presentation the lesson renderer already established.
|
|
317
|
+
*
|
|
318
|
+
* Identical on purpose. A reader who has learnt to read evidence in a lesson
|
|
319
|
+
* has learnt to read it here, and the shape is the shared `citation` from the
|
|
320
|
+
* common contract rather than anything this kind invented.
|
|
321
|
+
*/
|
|
322
|
+
function renderEvidence(evidence) {
|
|
323
|
+
if (!evidence || evidence.length === 0) return "";
|
|
324
|
+
return [
|
|
325
|
+
'<div class="pf-evidence">',
|
|
326
|
+
`<div class="pf-evidence-label">${esc(UI.evidence)}</div>`,
|
|
327
|
+
"<ul>",
|
|
328
|
+
...evidence.map((citation) => {
|
|
329
|
+
const parts = [`<span class="pf-cite-path">${esc(citation.path)}</span>`];
|
|
330
|
+
if (citation.lines) {
|
|
331
|
+
parts.push(`<span class="pf-cite-lines">lines ${citation.lines[0]}` +
|
|
332
|
+
`–${citation.lines[1]}</span>`);
|
|
333
|
+
}
|
|
334
|
+
if (citation.commit) {
|
|
335
|
+
parts.push(`<span class="pf-cite-commit">@ ${esc(citation.commit)}</span>`);
|
|
336
|
+
}
|
|
337
|
+
return `<li>${parts.join("")}</li>`;
|
|
338
|
+
}),
|
|
339
|
+
"</ul>",
|
|
340
|
+
"</div>",
|
|
341
|
+
].join("\n");
|
|
342
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text goes into HTML exactly one way: through here.
|
|
3
|
+
*
|
|
4
|
+
* The specification carries no HTML, by contract. That contract is only worth
|
|
5
|
+
* anything if the renderer enforces it, so every producer-supplied string is
|
|
6
|
+
* escaped on the way out and there is no raw-insertion helper to reach for.
|
|
7
|
+
* `<b>bold</b>` in a title renders as those nine characters, visibly, which is
|
|
8
|
+
* the correct behaviour: the producer owns content, the renderer owns markup,
|
|
9
|
+
* and a producer smuggling markup through content is the boundary failing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const HTML = new Map([
|
|
13
|
+
["&", "&"],
|
|
14
|
+
["<", "<"],
|
|
15
|
+
[">", ">"],
|
|
16
|
+
['"', """],
|
|
17
|
+
["'", "'"],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** Escape for element content and for double-quoted attribute values alike. */
|
|
21
|
+
export function esc(value) {
|
|
22
|
+
return String(value).replace(/[&<>"']/g, (character) => HTML.get(character));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A DOM id derived from specification identifiers alone.
|
|
27
|
+
*
|
|
28
|
+
* Every id the renderer emits is built from ids the producer supplied, joined
|
|
29
|
+
* with a separator the identifier pattern forbids. No counter, no hash, no
|
|
30
|
+
* insertion order: the same specification yields the same ids on every machine.
|
|
31
|
+
*/
|
|
32
|
+
export function domId(...parts) {
|
|
33
|
+
return parts.filter((part) => part !== undefined && part !== "").join("--");
|
|
34
|
+
}
|