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,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `lesson` renderer.
|
|
3
|
+
*
|
|
4
|
+
* One path serves both consumers. `learn-feature` supplies one module and
|
|
5
|
+
* `learn-codebase` supplies many, and nothing below asks which: the module list
|
|
6
|
+
* is rendered by iteration, the navigation is built from the same list, and
|
|
7
|
+
* there is no mode flag, no consumer branch, and no count-dependent layout.
|
|
8
|
+
* When 50.4 brings the multi-module case it proves this rather than adding to
|
|
9
|
+
* it.
|
|
10
|
+
*
|
|
11
|
+
* Every label here is renderer-supplied interface language. The producer names
|
|
12
|
+
* modules, concepts, steps, and questions; the renderer names the furniture
|
|
13
|
+
* around them and never touches what they assert.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { esc, domId } from "./escape.mjs";
|
|
17
|
+
import { renderShell, renderNav } from "./shell.mjs";
|
|
18
|
+
|
|
19
|
+
/** Renderer-owned interface language. The producer supplies none of this. */
|
|
20
|
+
const UI = Object.freeze({
|
|
21
|
+
eyebrow: "Pathfinder lesson",
|
|
22
|
+
navLabel: "Modules",
|
|
23
|
+
objectives: "What you should be able to do",
|
|
24
|
+
evidence: "Evidence",
|
|
25
|
+
requires: "Builds on",
|
|
26
|
+
leadsTo: "Leads to",
|
|
27
|
+
concept: "Concept",
|
|
28
|
+
walkthrough: "Walkthrough",
|
|
29
|
+
practice: "Practice",
|
|
30
|
+
quiz: "Check your understanding",
|
|
31
|
+
showAnswer: "Show the answer",
|
|
32
|
+
hints: "Hints",
|
|
33
|
+
correct: "Correct",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Note what is *not* in UI: any sentence about the artifact having been
|
|
37
|
+
// checked. That claim describes the engine's own work rather than the lesson's
|
|
38
|
+
// subject, so it belongs to the shell and is gated on an attestation there. A
|
|
39
|
+
// kind renderer has nothing to say about whether it was validated.
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} spec a `lesson` specification
|
|
43
|
+
* @param {object} [verification] an attestation, passed through to the shell.
|
|
44
|
+
* Absent for an ordinary render, which then makes no claim about having
|
|
45
|
+
* been checked.
|
|
46
|
+
* @returns {string} a complete HTML document
|
|
47
|
+
*/
|
|
48
|
+
export function renderLesson(spec, verification) {
|
|
49
|
+
const modules = spec.lesson.modules;
|
|
50
|
+
const titleById = new Map(modules.map((module) => [module.id, module.title]));
|
|
51
|
+
|
|
52
|
+
const nav = renderNav(
|
|
53
|
+
modules.map((module) => ({ id: domId("m", module.id), label: module.title })),
|
|
54
|
+
UI.navLabel,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const body = [
|
|
58
|
+
renderLead(spec),
|
|
59
|
+
...modules.map((module, index) => renderModule(module, index, titleById)),
|
|
60
|
+
].join("\n");
|
|
61
|
+
|
|
62
|
+
return renderShell({
|
|
63
|
+
lang: spec.artifact.locale ?? "en",
|
|
64
|
+
title: spec.artifact.title,
|
|
65
|
+
eyebrow: UI.eyebrow,
|
|
66
|
+
description: spec.artifact.summary ?? spec.artifact.subtitle,
|
|
67
|
+
nav,
|
|
68
|
+
body,
|
|
69
|
+
source: spec.source,
|
|
70
|
+
verification,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderLead(spec) {
|
|
75
|
+
const { artifact, lesson } = spec;
|
|
76
|
+
const out = ['<div class="pf-lead">', `<h1>${esc(artifact.title)}</h1>`];
|
|
77
|
+
|
|
78
|
+
if (artifact.subtitle) {
|
|
79
|
+
out.push(`<p class="pf-lead-sub">${esc(artifact.subtitle)}</p>`);
|
|
80
|
+
}
|
|
81
|
+
if (artifact.summary) {
|
|
82
|
+
out.push(`<div class="pf-lead-summary"><p>${esc(artifact.summary)}</p></div>`);
|
|
83
|
+
}
|
|
84
|
+
if (lesson.objectives) {
|
|
85
|
+
out.push(
|
|
86
|
+
'<div class="pf-objectives">',
|
|
87
|
+
`<div class="pf-kicker">${esc(UI.objectives)}</div>`,
|
|
88
|
+
"<ul>",
|
|
89
|
+
...lesson.objectives.map((objective) => `<li>${esc(objective)}</li>`),
|
|
90
|
+
"</ul>",
|
|
91
|
+
"</div>",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
out.push("</div>");
|
|
95
|
+
return out.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function renderModule(module, index, titleById) {
|
|
99
|
+
const id = domId("m", module.id);
|
|
100
|
+
const out = [
|
|
101
|
+
`<section class="pf-module" id="${esc(id)}" aria-labelledby="${esc(domId(id, "h"))}">`,
|
|
102
|
+
'<div class="pf-module-head">',
|
|
103
|
+
`<div class="pf-module-index">Module ${index + 1}</div>`,
|
|
104
|
+
`<h2 class="pf-module-title" id="${esc(domId(id, "h"))}">${esc(module.title)}</h2>`,
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
if (module.summary) {
|
|
108
|
+
out.push(`<p class="pf-module-summary">${esc(module.summary)}</p>`);
|
|
109
|
+
}
|
|
110
|
+
if ((module.requires ?? []).length > 0) {
|
|
111
|
+
const links = module.requires.map((required) =>
|
|
112
|
+
`<a href="#${esc(domId("m", required))}">${esc(titleById.get(required))}</a>`);
|
|
113
|
+
out.push(`<p class="pf-requires">${esc(UI.requires)}: ${links.join(", ")}</p>`);
|
|
114
|
+
}
|
|
115
|
+
out.push("</div>");
|
|
116
|
+
|
|
117
|
+
for (const section of module.sections) out.push(renderSection(section));
|
|
118
|
+
out.push("</section>");
|
|
119
|
+
return out.join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderSection(section) {
|
|
123
|
+
switch (section.type) {
|
|
124
|
+
case "prose": return renderProse(section);
|
|
125
|
+
case "concept": return renderConcept(section);
|
|
126
|
+
case "code": return renderCode(section);
|
|
127
|
+
case "flow": return renderFlow(section);
|
|
128
|
+
case "quiz": return renderQuiz(section);
|
|
129
|
+
case "exercise": return renderExercise(section);
|
|
130
|
+
// Unreachable: the structural layer rejects any other type before a
|
|
131
|
+
// specification reaches the renderer. Thrown rather than rendered as a
|
|
132
|
+
// fallback, because the one thing a renderer must never do with an
|
|
133
|
+
// unsupported shape is improvise something.
|
|
134
|
+
default: throw new Error(`no renderer for section type ${JSON.stringify(section.type)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The section wrapper every type shares: id, optional heading, one card. */
|
|
139
|
+
function wrap(section, kicker, inner, extraClass = "") {
|
|
140
|
+
const id = domId("s", section.id);
|
|
141
|
+
const out = [`<section class="pf-section" id="${esc(id)}">`];
|
|
142
|
+
if (section.title) {
|
|
143
|
+
out.push(`<h3 class="pf-section-title" id="${esc(domId(id, "h"))}">${esc(section.title)}</h3>`);
|
|
144
|
+
}
|
|
145
|
+
out.push(`<div class="pf-card${extraClass}">`);
|
|
146
|
+
if (kicker) out.push(`<span class="pf-kicker">${esc(kicker)}</span>`);
|
|
147
|
+
out.push(inner, "</div>", "</section>");
|
|
148
|
+
return out.join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function paragraphs(body) {
|
|
152
|
+
return body.map((text) => `<p>${esc(text)}</p>`).join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderProse(section) {
|
|
156
|
+
return wrap(section, null, paragraphs(section.body));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderConcept(section) {
|
|
160
|
+
return wrap(
|
|
161
|
+
section,
|
|
162
|
+
UI.concept,
|
|
163
|
+
[paragraphs(section.body), renderEvidence(section.evidence)].filter(Boolean).join("\n"),
|
|
164
|
+
" pf-card--concept",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A code excerpt, emitted verbatim and escaped. The `language` names the
|
|
170
|
+
* language for the reader; it selects no highlighter, because highlighting is
|
|
171
|
+
* either a dependency or a hand-rolled tokeniser, and both are ways for the
|
|
172
|
+
* renderer to start asserting things about content it was given literally.
|
|
173
|
+
*/
|
|
174
|
+
function renderCode(section) {
|
|
175
|
+
const first = section.first_line ?? 1;
|
|
176
|
+
const inner = [
|
|
177
|
+
`<span class="pf-code-lang">${esc(section.language)}</span>`,
|
|
178
|
+
"<pre><code>",
|
|
179
|
+
...section.lines.map((line, index) =>
|
|
180
|
+
`<span class="pf-code-line"><span class="pf-code-no">${first + index}</span>` +
|
|
181
|
+
`${esc(line)}</span>`),
|
|
182
|
+
"</code></pre>",
|
|
183
|
+
].join("\n");
|
|
184
|
+
|
|
185
|
+
const out = [`<section class="pf-section" id="${esc(domId("s", section.id))}">`];
|
|
186
|
+
if (section.title) out.push(`<h3 class="pf-section-title">${esc(section.title)}</h3>`);
|
|
187
|
+
out.push('<figure class="pf-code">');
|
|
188
|
+
if (section.caption) out.push(`<figcaption>${esc(section.caption)}</figcaption>`);
|
|
189
|
+
out.push(`<div class="pf-code-frame">${inner}</div>`);
|
|
190
|
+
const evidence = renderEvidence(section.evidence);
|
|
191
|
+
if (evidence) out.push(`<div class="pf-card">${evidence}</div>`);
|
|
192
|
+
out.push("</figure>", "</section>");
|
|
193
|
+
return out.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function renderFlow(section) {
|
|
197
|
+
const titleById = new Map(section.steps.map((step) => [step.id, step.title]));
|
|
198
|
+
const steps = section.steps.map((step) => {
|
|
199
|
+
const out = ["<li>", `<div class="pf-step-title">${esc(step.title)}</div>`];
|
|
200
|
+
if (step.detail) out.push(`<p class="pf-step-detail">${esc(step.detail)}</p>`);
|
|
201
|
+
if ((step.next ?? []).length > 0) {
|
|
202
|
+
const names = step.next.map((id) => esc(titleById.get(id) ?? id)).join(", ");
|
|
203
|
+
out.push(`<p class="pf-step-next">${esc(UI.leadsTo)}: ${names}</p>`);
|
|
204
|
+
}
|
|
205
|
+
const evidence = renderEvidence(step.evidence);
|
|
206
|
+
if (evidence) out.push(evidence);
|
|
207
|
+
out.push("</li>");
|
|
208
|
+
return out.join("\n");
|
|
209
|
+
});
|
|
210
|
+
return wrap(section, UI.walkthrough, `<ol class="pf-flow">\n${steps.join("\n")}\n</ol>`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function renderQuiz(section) {
|
|
214
|
+
const questions = section.questions.map((question) => {
|
|
215
|
+
const name = domId("q", section.id, question.id);
|
|
216
|
+
const options = question.options.map((option, index) => {
|
|
217
|
+
const optionId = domId(name, String(index));
|
|
218
|
+
const correct = index === question.answer;
|
|
219
|
+
return [
|
|
220
|
+
`<label class="pf-option" for="${esc(optionId)}">`,
|
|
221
|
+
`<input type="radio" id="${esc(optionId)}" name="${esc(name)}" ` +
|
|
222
|
+
`value="${index}" data-pf-answer="${correct ? "correct" : "other"}">`,
|
|
223
|
+
`<span>${esc(option)}</span>`,
|
|
224
|
+
'<span class="pf-verdict" aria-live="polite"></span>',
|
|
225
|
+
"</label>",
|
|
226
|
+
].join("");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const reveal = [
|
|
230
|
+
'<details class="pf-reveal">',
|
|
231
|
+
`<summary>${esc(UI.showAnswer)}</summary>`,
|
|
232
|
+
`<p><strong>${esc(UI.correct)}:</strong> ${esc(question.options[question.answer])}</p>`,
|
|
233
|
+
question.explanation ? `<p>${esc(question.explanation)}</p>` : null,
|
|
234
|
+
renderEvidence(question.evidence),
|
|
235
|
+
"</details>",
|
|
236
|
+
].filter(Boolean).join("\n");
|
|
237
|
+
|
|
238
|
+
return [
|
|
239
|
+
`<fieldset class="pf-question" data-pf-question="${esc(name)}">`,
|
|
240
|
+
`<legend>${esc(question.prompt)}</legend>`,
|
|
241
|
+
`<div class="pf-options">${options.join("")}</div>`,
|
|
242
|
+
reveal,
|
|
243
|
+
"</fieldset>",
|
|
244
|
+
].join("\n");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return wrap(section, UI.quiz, `<div class="pf-quiz">\n${questions.join("\n")}\n</div>`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function renderExercise(section) {
|
|
251
|
+
const out = [paragraphs(section.body)];
|
|
252
|
+
if (section.hints) {
|
|
253
|
+
out.push([
|
|
254
|
+
'<details class="pf-reveal">',
|
|
255
|
+
`<summary>${esc(UI.hints)}</summary>`,
|
|
256
|
+
"<ol>",
|
|
257
|
+
...section.hints.map((hint) => `<li>${esc(hint)}</li>`),
|
|
258
|
+
"</ol>",
|
|
259
|
+
"</details>",
|
|
260
|
+
].join("\n"));
|
|
261
|
+
}
|
|
262
|
+
const evidence = renderEvidence(section.evidence);
|
|
263
|
+
if (evidence) out.push(evidence);
|
|
264
|
+
return wrap(section, UI.practice, out.join("\n"));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Citations, in one presentation used everywhere evidence appears.
|
|
269
|
+
*
|
|
270
|
+
* The shape is the same in a concept, a flow step, and a quiz answer, and it
|
|
271
|
+
* will be the same in whatever kind comes next. A reader who learns to read one
|
|
272
|
+
* of these has learnt to read all of them.
|
|
273
|
+
*/
|
|
274
|
+
function renderEvidence(evidence) {
|
|
275
|
+
if (!evidence || evidence.length === 0) return "";
|
|
276
|
+
return [
|
|
277
|
+
'<div class="pf-evidence">',
|
|
278
|
+
`<div class="pf-evidence-label">${esc(UI.evidence)}</div>`,
|
|
279
|
+
"<ul>",
|
|
280
|
+
...evidence.map((citation) => {
|
|
281
|
+
const parts = [`<span class="pf-cite-path">${esc(citation.path)}</span>`];
|
|
282
|
+
if (citation.lines) {
|
|
283
|
+
parts.push(`<span class="pf-cite-lines">lines ${citation.lines[0]}` +
|
|
284
|
+
`–${citation.lines[1]}</span>`);
|
|
285
|
+
}
|
|
286
|
+
if (citation.commit) {
|
|
287
|
+
parts.push(`<span class="pf-cite-commit">@ ${esc(citation.commit)}</span>`);
|
|
288
|
+
}
|
|
289
|
+
return `<li>${parts.join("")}</li>`;
|
|
290
|
+
}),
|
|
291
|
+
"</ul>",
|
|
292
|
+
"</div>",
|
|
293
|
+
].join("\n");
|
|
294
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared shell every artifact kind renders into.
|
|
3
|
+
*
|
|
4
|
+
* A kind supplies a lead, a navigation list, and a body. Everything else on the
|
|
5
|
+
* page — the document head, the header, the mark, the theme control, the skip
|
|
6
|
+
* link, the provenance block, and every word of interface language in them — is
|
|
7
|
+
* the renderer's, defined once here. That is what "adding a kind must not
|
|
8
|
+
* require restating the identity" means in practice: a second kind imports this
|
|
9
|
+
* function and passes strings to it.
|
|
10
|
+
*
|
|
11
|
+
* Every string the renderer emits about the artifact's *subject* comes from the
|
|
12
|
+
* specification. Every string it emits about the *interface* is written here.
|
|
13
|
+
* The renderer asserts no domain content: it never summarises, rewords, or
|
|
14
|
+
* invents a producer's claim.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { RENDERER_VERSION } from "../version.mjs";
|
|
18
|
+
import { isAttestation } from "../verification.mjs";
|
|
19
|
+
import { esc } from "./escape.mjs";
|
|
20
|
+
import { THEME_CSS } from "./theme.mjs";
|
|
21
|
+
import { BEHAVIOR_JS } from "./behavior.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The Pathfinder mark: the blaze stack from `assets/logo.svg`, drawn in
|
|
25
|
+
* `currentColor` so it takes the accent in both themes. Inlined rather than
|
|
26
|
+
* linked — an artifact that opens from `file://` cannot fetch a logo, and a
|
|
27
|
+
* broken image in the header is worse than no header.
|
|
28
|
+
*/
|
|
29
|
+
const MARK = '<svg class="pf-mark" width="26" height="26" viewBox="0 0 32 32" ' +
|
|
30
|
+
'aria-hidden="true" focusable="false"><g fill="currentColor">' +
|
|
31
|
+
'<rect x="4" y="23" width="24" height="6.4" rx="3.2" transform="rotate(-3 16 26.2)"/>' +
|
|
32
|
+
'<rect x="7" y="14.6" width="18" height="6" rx="3" transform="rotate(4 16 17.6)"/>' +
|
|
33
|
+
'<rect x="9.6" y="7" width="12.8" height="5.4" rx="2.7" transform="rotate(-6 16 9.7)"/>' +
|
|
34
|
+
'<rect x="12.4" y="1" width="7.2" height="4.4" rx="2.2" transform="rotate(5 16 3.2)"/>' +
|
|
35
|
+
'</g></svg>';
|
|
36
|
+
|
|
37
|
+
/** The same mark as a favicon, percent-encoded inline. No network request. */
|
|
38
|
+
const FAVICON = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20" +
|
|
39
|
+
"viewBox='0%200%2032%2032'%3E%3Cg%20fill='%23E0611F'%3E" +
|
|
40
|
+
"%3Crect%20x='4'%20y='23'%20width='24'%20height='6.4'%20rx='3.2'%20transform='rotate(-3%2016%2026.2)'/%3E" +
|
|
41
|
+
"%3Crect%20x='7'%20y='14.6'%20width='18'%20height='6'%20rx='3'%20transform='rotate(4%2016%2017.6)'/%3E" +
|
|
42
|
+
"%3Crect%20x='9.6'%20y='7'%20width='12.8'%20height='5.4'%20rx='2.7'%20transform='rotate(-6%2016%209.7)'/%3E" +
|
|
43
|
+
"%3Crect%20x='12.4'%20y='1'%20width='7.2'%20height='4.4'%20rx='2.2'%20transform='rotate(5%2016%203.2)'/%3E" +
|
|
44
|
+
"%3C/g%3E%3C/svg%3E";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The verification sentence below is deliberately here rather than in a kind
|
|
48
|
+
* renderer: it describes what the *engine* did, not what a lesson is about, so
|
|
49
|
+
* every kind that renders into this shell says it the same way or not at all.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} parts
|
|
52
|
+
* @param {string} parts.lang BCP 47 tag from the specification, or "en"
|
|
53
|
+
* @param {string} parts.title artifact title
|
|
54
|
+
* @param {string} parts.eyebrow what kind of artifact this is
|
|
55
|
+
* @param {string} [parts.description] meta description
|
|
56
|
+
* @param {string} [parts.style] a kind's own stylesheet, appended to the
|
|
57
|
+
* shared theme inside the one `<style>` element. Absent for a kind that
|
|
58
|
+
* needs none, and then not one byte of the document changes.
|
|
59
|
+
* @param {string} [parts.behavior] a kind's own inline script, appended to the
|
|
60
|
+
* shared behaviour inside the one `<script>` element, on the same terms
|
|
61
|
+
* and for the same reason: a lesson has no graph to traverse, and
|
|
62
|
+
* shipping it traversal code would put script in an artifact that can
|
|
63
|
+
* never run it. Absent for a kind that needs none, and then not one
|
|
64
|
+
* byte of the document changes.
|
|
65
|
+
* @param {string} parts.nav rendered navigation HTML
|
|
66
|
+
* @param {string} parts.body rendered lead and content HTML
|
|
67
|
+
* @param {object} [parts.source] the specification's source identity, when
|
|
68
|
+
* it declares one. A kind describing a system with no repository behind
|
|
69
|
+
* it has none, and then no provenance row is invented for it.
|
|
70
|
+
* @param {"derived"|"proposed"} [parts.provenance] what kind of claim the
|
|
71
|
+
* artifact makes, for the kinds that distinguish. Absent for `lesson`.
|
|
72
|
+
* @param {object} [parts.verification] an attestation, when — and only when —
|
|
73
|
+
* this engine validated the specification and the validation passed
|
|
74
|
+
* @returns {string} a complete HTML document
|
|
75
|
+
*/
|
|
76
|
+
export function renderShell(parts) {
|
|
77
|
+
const lines = [
|
|
78
|
+
"<!doctype html>",
|
|
79
|
+
`<html lang="${esc(parts.lang)}" data-pf-theme="auto">`,
|
|
80
|
+
"<head>",
|
|
81
|
+
'<meta charset="utf-8">',
|
|
82
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
83
|
+
`<title>${esc(parts.title)}</title>`,
|
|
84
|
+
parts.description
|
|
85
|
+
? `<meta name="description" content="${esc(parts.description)}">`
|
|
86
|
+
: null,
|
|
87
|
+
`<meta name="generator" content="Pathfinder render-artifact ${esc(RENDERER_VERSION)}">`,
|
|
88
|
+
`<link rel="icon" href="${FAVICON}">`,
|
|
89
|
+
`<style>${THEME_CSS}${parts.style ? `\n${parts.style}\n` : ""}</style>`,
|
|
90
|
+
"</head>",
|
|
91
|
+
"<body>",
|
|
92
|
+
'<a class="pf-skip" href="#pf-content">Skip to content</a>',
|
|
93
|
+
'<header class="pf-header">',
|
|
94
|
+
MARK,
|
|
95
|
+
'<div class="pf-identity">',
|
|
96
|
+
`<div class="pf-eyebrow">${esc(parts.eyebrow)}</div>`,
|
|
97
|
+
`<div class="pf-title">${esc(parts.title)}</div>`,
|
|
98
|
+
"</div>",
|
|
99
|
+
// Hidden until the script un-hides it: a control that cannot work should
|
|
100
|
+
// not be offered. Without scripting the theme still follows the system.
|
|
101
|
+
'<button type="button" class="pf-theme-toggle" id="pf-theme-toggle" hidden>Theme: auto</button>',
|
|
102
|
+
"</header>",
|
|
103
|
+
'<div class="pf-layout">',
|
|
104
|
+
parts.nav,
|
|
105
|
+
'<main class="pf-main" id="pf-content">',
|
|
106
|
+
parts.body,
|
|
107
|
+
"</main>",
|
|
108
|
+
"</div>",
|
|
109
|
+
renderProvenance(parts.source, parts.verification, parts.provenance),
|
|
110
|
+
`<script>${BEHAVIOR_JS}${parts.behavior ? `\n${parts.behavior}` : ""}</script>`,
|
|
111
|
+
"</body>",
|
|
112
|
+
"</html>",
|
|
113
|
+
"",
|
|
114
|
+
];
|
|
115
|
+
return lines.filter((line) => line !== null).join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What this artifact claims about itself, and when it is entitled to.
|
|
120
|
+
*
|
|
121
|
+
* The provenance rows are facts copied out of the specification, and only the
|
|
122
|
+
* ones it actually carries. The verification sentence is a claim about work the
|
|
123
|
+
* engine performed, so it appears only against a real attestation — which only
|
|
124
|
+
* `verification.attest()` can mint, and only from a validation that passed.
|
|
125
|
+
*
|
|
126
|
+
* Rendering without one emits no verification language whatsoever. Not a
|
|
127
|
+
* hedge, not "unverified", not a placeholder: an artifact that cannot vouch for
|
|
128
|
+
* itself says nothing on the subject, and the absence of the sentence is the
|
|
129
|
+
* signal. Saying "unverified" would still be the renderer making a claim about
|
|
130
|
+
* a process it did not observe.
|
|
131
|
+
*
|
|
132
|
+
* Even with the attestation, note what the sentences are careful not to say.
|
|
133
|
+
* Each scopes itself to what was checked deterministically and hands the
|
|
134
|
+
* question of whether the page reads well back to a person, because
|
|
135
|
+
* deterministic validation never establishes that and must never be reported as
|
|
136
|
+
* if it did. None of them says the system drawn is correct, and the `derived`
|
|
137
|
+
* one in particular does not: what was verified is that the cited material is
|
|
138
|
+
* there at the cited commit. Provenance, not truth.
|
|
139
|
+
*
|
|
140
|
+
* **Three states, three sentences, and none of them selectable.** There is no
|
|
141
|
+
* field in any specification that chooses between these, softens one, or asks
|
|
142
|
+
* for one it has not earned. The words belong to the renderer for the same
|
|
143
|
+
* reason the claim does — a producer vouching for its own work is precisely
|
|
144
|
+
* what this design exists to make impossible — and they live in the shared
|
|
145
|
+
* shell rather than in a kind renderer because they describe what the *engine*
|
|
146
|
+
* did, which is the same whatever kind rendered into it.
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `lesson`, and any kind that draws no provenance distinction. Unchanged from
|
|
151
|
+
* the wording that shipped, deliberately: the lesson artifact's language is not
|
|
152
|
+
* what this contract set out to alter.
|
|
153
|
+
*/
|
|
154
|
+
const CHECKED =
|
|
155
|
+
"This artifact was checked deterministically: its specification satisfied " +
|
|
156
|
+
"the schema, its references and graphs resolved, and every citation above " +
|
|
157
|
+
"was verified against the commit named here. That is what was checked. It " +
|
|
158
|
+
"is not a judgement that the page reads well or looks right — a person has " +
|
|
159
|
+
"to open it to know that.";
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `derived`: the strongest sentence the engine can write, and still not a
|
|
163
|
+
* statement that the architecture is right. Every node, edge and claim carried
|
|
164
|
+
* a citation, and each resolved — which establishes that the cited material is
|
|
165
|
+
* in the repository at that commit, not that the reading of it is correct.
|
|
166
|
+
*/
|
|
167
|
+
const CHECKED_DERIVED =
|
|
168
|
+
"This diagram was checked deterministically against the commit named here: " +
|
|
169
|
+
"its specification satisfied the schema, its identifiers, references and " +
|
|
170
|
+
"graphs resolved, and every component, relationship and claim in it carried " +
|
|
171
|
+
"a citation that was verified at that commit. What that establishes is that " +
|
|
172
|
+
"the cited material is there to be read. It is not a finding that the " +
|
|
173
|
+
"architecture drawn here is correct, complete, or the best reading of that " +
|
|
174
|
+
"material, and it is not a judgement that the page reads well — a person " +
|
|
175
|
+
"has to open it to know either.";
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `proposed`, with a source and at least one citation that resolved. The
|
|
179
|
+
* citations were checked; the design was not. The second half of this sentence
|
|
180
|
+
* is the load-bearing half, and it is why this state cannot borrow the wording
|
|
181
|
+
* above: a reader who met "verified against the commit" on a proposed
|
|
182
|
+
* architecture would reasonably conclude the architecture was the thing found
|
|
183
|
+
* in the repository.
|
|
184
|
+
*/
|
|
185
|
+
const CHECKED_PROPOSED =
|
|
186
|
+
"This diagram describes a proposed design. Its specification satisfied the " +
|
|
187
|
+
"schema, its identifiers, references and graphs resolved, and the citations " +
|
|
188
|
+
"it supplied were verified against the commit named here. Those citations " +
|
|
189
|
+
"establish that the material they name exists at that commit — they do not " +
|
|
190
|
+
"establish that the system drawn here exists. Nothing was checked about " +
|
|
191
|
+
"whether it does, and nothing here should be read as saying it does.";
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `proposed`, with no source at all. Not a verification sentence: there is
|
|
195
|
+
* nothing to verify and the artifact says so in place of claiming anything.
|
|
196
|
+
* Shown whether or not an attestation exists, because it is a fact about the
|
|
197
|
+
* specification's own shape rather than a report of work the engine did.
|
|
198
|
+
*/
|
|
199
|
+
const NO_SOURCE =
|
|
200
|
+
"This diagram describes an intended system. It names no repository and no " +
|
|
201
|
+
"commit, so it carries no citations and none were checked — there is " +
|
|
202
|
+
"nothing here that was verified against a source. It is a proposal, and it " +
|
|
203
|
+
"makes no claim about what currently exists.";
|
|
204
|
+
|
|
205
|
+
function renderProvenance(source, verification, provenance) {
|
|
206
|
+
// Built from what is present rather than from a fixed shape. A specification
|
|
207
|
+
// with no source gets no repository row, no commit row and no timestamp —
|
|
208
|
+
// none of them invented from the working directory, the environment, or a
|
|
209
|
+
// clock, because there is no honest value for them and a plausible one would
|
|
210
|
+
// be worse than none.
|
|
211
|
+
const rows = [];
|
|
212
|
+
if (source?.repo) rows.push(["Repository", source.repo]);
|
|
213
|
+
if (source?.commit) rows.push(["Commit", source.commit]);
|
|
214
|
+
if (source?.generated_at) rows.push(["Specification generated", source.generated_at]);
|
|
215
|
+
rows.push(["Renderer", `Pathfinder render-artifact ${RENDERER_VERSION}`]);
|
|
216
|
+
|
|
217
|
+
return [
|
|
218
|
+
'<footer class="pf-provenance">',
|
|
219
|
+
"<dl>",
|
|
220
|
+
...rows.flatMap(([term, value]) =>
|
|
221
|
+
[`<dt>${esc(term)}</dt>`, `<dd>${esc(value)}</dd>`]),
|
|
222
|
+
"</dl>",
|
|
223
|
+
provenanceNote(source, verification, provenance),
|
|
224
|
+
"</footer>",
|
|
225
|
+
].filter((line) => line !== null).join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The one sentence this artifact is entitled to, or none.
|
|
230
|
+
*
|
|
231
|
+
* Two conditions gate every verification claim, and both are necessary. The
|
|
232
|
+
* attestation proves this engine validated the specification and that the
|
|
233
|
+
* validation passed. The resolved-citation count proves there was something to
|
|
234
|
+
* check: a specification carrying no citations passes the evidence layer by
|
|
235
|
+
* having nothing to fail, and a claim resting on that would be a stronger
|
|
236
|
+
* statement than anybody made. Vacuous success earns no sentence.
|
|
237
|
+
*/
|
|
238
|
+
function provenanceNote(source, verification, provenance) {
|
|
239
|
+
const caveat = (text) => `<p class="pf-caveat">${esc(text)}</p>`;
|
|
240
|
+
|
|
241
|
+
// A proposal with no repository behind it. Said plainly, and said whatever
|
|
242
|
+
// else is true, because it is a fact about the specification rather than a
|
|
243
|
+
// report of anything the engine did.
|
|
244
|
+
if (provenance === "proposed" && !source) return caveat(NO_SOURCE);
|
|
245
|
+
|
|
246
|
+
if (!isAttestation(verification)) return null;
|
|
247
|
+
if (!(verification.resolvedCitations >= 1)) return null;
|
|
248
|
+
|
|
249
|
+
if (provenance === "derived") return caveat(CHECKED_DERIVED);
|
|
250
|
+
if (provenance === "proposed") return caveat(CHECKED_PROPOSED);
|
|
251
|
+
return caveat(CHECKED);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The navigation list. One markup shape whatever the module count: a single
|
|
256
|
+
* module renders the same list a sixty-module portal does, so nothing branches
|
|
257
|
+
* on the number of entries and a later kind inherits the behaviour for free.
|
|
258
|
+
*/
|
|
259
|
+
export function renderNav(entries, label) {
|
|
260
|
+
if (entries.length === 0) return "";
|
|
261
|
+
return [
|
|
262
|
+
`<nav class="pf-nav" aria-label="${esc(label)}">`,
|
|
263
|
+
`<div class="pf-nav-label">${esc(label)}</div>`,
|
|
264
|
+
'<ul class="pf-nav-list">',
|
|
265
|
+
...entries.map((entry, index) => [
|
|
266
|
+
"<li>",
|
|
267
|
+
`<a class="pf-nav-link" href="#${esc(entry.id)}" data-pf-nav="${esc(entry.id)}">`,
|
|
268
|
+
`<span class="pf-nav-index">${index + 1}</span>${esc(entry.label)}`,
|
|
269
|
+
"</a>",
|
|
270
|
+
"</li>",
|
|
271
|
+
].join("")),
|
|
272
|
+
"</ul>",
|
|
273
|
+
"</nav>",
|
|
274
|
+
].join("\n");
|
|
275
|
+
}
|