@squinch/core 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/NOTICE +41 -0
- package/dist/api.d.ts +91 -0
- package/dist/api.js +232 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +9 -0
- package/dist/diff/diff.d.ts +30 -0
- package/dist/diff/diff.js +365 -0
- package/dist/fonts.generated.d.ts +1 -0
- package/dist/fonts.generated.js +6 -0
- package/dist/grammar/parser.js +22 -0
- package/dist/grammar/parser.terms.js +115 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/layout/layout.d.ts +197 -0
- package/dist/layout/layout.js +1721 -0
- package/dist/metrics.d.ts +20 -0
- package/dist/metrics.generated.d.ts +4 -0
- package/dist/metrics.generated.js +4 -0
- package/dist/metrics.js +57 -0
- package/dist/model/build.d.ts +8 -0
- package/dist/model/build.js +1343 -0
- package/dist/model/packs.d.ts +13 -0
- package/dist/model/packs.js +29 -0
- package/dist/model/source.d.ts +10 -0
- package/dist/model/source.js +28 -0
- package/dist/model/suggest.d.ts +2 -0
- package/dist/model/suggest.js +24 -0
- package/dist/model/types.d.ts +226 -0
- package/dist/model/types.js +24 -0
- package/dist/packs/node-fs.d.ts +1 -0
- package/dist/packs/node-fs.js +37 -0
- package/dist/packs/registry.d.ts +61 -0
- package/dist/packs/registry.js +122 -0
- package/dist/packs/sanitize.d.ts +12 -0
- package/dist/packs/sanitize.js +127 -0
- package/dist/packs/sysGlyphs.d.ts +2 -0
- package/dist/packs/sysGlyphs.js +21 -0
- package/dist/render/adaptive.d.ts +13 -0
- package/dist/render/adaptive.js +112 -0
- package/dist/render/html/runtime.d.ts +1 -0
- package/dist/render/html/runtime.generated.d.ts +1 -0
- package/dist/render/html/runtime.generated.js +6 -0
- package/dist/render/html/runtime.js +362 -0
- package/dist/render/html.d.ts +39 -0
- package/dist/render/html.js +235 -0
- package/dist/render/svg.d.ts +75 -0
- package/dist/render/svg.js +1403 -0
- package/dist/render/validate.d.ts +4 -0
- package/dist/render/validate.js +9 -0
- package/dist/themes/index.d.ts +84 -0
- package/dist/themes/index.js +90 -0
- package/dist/view/dive.d.ts +55 -0
- package/dist/view/dive.js +57 -0
- package/dist/view/navigate.d.ts +38 -0
- package/dist/view/navigate.js +81 -0
- package/dist/view/resolve.d.ts +92 -0
- package/dist/view/resolve.js +591 -0
- package/fonts/inter-400.ttf +0 -0
- package/fonts/inter-500.ttf +0 -0
- package/fonts/inter-600.ttf +0 -0
- package/fonts/mono-400.ttf +0 -0
- package/metrics.json +510 -0
- package/package.json +89 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// The viewer that ships inside an exported diagram.
|
|
2
|
+
//
|
|
3
|
+
// This is the one place in the project where we ship JavaScript to a reader.
|
|
4
|
+
// It is a *sibling* of the SVGs, never inside one: every embedded SVG in the
|
|
5
|
+
// document is byte-comparable to `squinch render -o x.svg`, and the entry view
|
|
6
|
+
// is inline in the markup, so a reader whose browser (or wiki sanitizer) drops
|
|
7
|
+
// this script still sees a correct static diagram. That is the whole shape of
|
|
8
|
+
// the exception recorded in CLAUDE.md.
|
|
9
|
+
//
|
|
10
|
+
// It imports `../../view/dive.js` and `../../view/navigate.js` directly and is
|
|
11
|
+
// bundled by `scripts/gen-html-runtime.ts`, which is what makes "the export
|
|
12
|
+
// performs the same motion as the playground" a fact about the build rather
|
|
13
|
+
// than a comment nobody can check.
|
|
14
|
+
import { diveTransforms } from "../../view/dive.js";
|
|
15
|
+
import { hop, upView, viewForPath } from "../../view/navigate.js";
|
|
16
|
+
const $ = (sel) => document.querySelector(sel);
|
|
17
|
+
function boot() {
|
|
18
|
+
const data = JSON.parse($("#sq-data").textContent || "{}");
|
|
19
|
+
const live = $("#sq-live");
|
|
20
|
+
const ghost = $("#sq-ghost");
|
|
21
|
+
const stage = $("#sq-stage");
|
|
22
|
+
const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
23
|
+
let view = data.entry;
|
|
24
|
+
let theme = data.themes[0];
|
|
25
|
+
let step = 0;
|
|
26
|
+
let presenting = false;
|
|
27
|
+
// The reader's own preference wins over the author's, when the file carries
|
|
28
|
+
// a palette that matches it — the same rule an adaptive SVG follows.
|
|
29
|
+
if (data.themes.length > 1 && matchMedia("(prefers-color-scheme: dark)").matches) {
|
|
30
|
+
const dark = data.themes.find((t) => t.includes("dark"));
|
|
31
|
+
if (dark)
|
|
32
|
+
theme = dark;
|
|
33
|
+
}
|
|
34
|
+
// The entry view is inline rather than in a <template>, so that a reader
|
|
35
|
+
// whose browser never runs this script still sees a diagram. That means it is
|
|
36
|
+
// the one body with no template to clone from — captured here at boot, before
|
|
37
|
+
// anything can replace it, or switching *back* to the entry palette would
|
|
38
|
+
// silently find nothing.
|
|
39
|
+
const inlineKey = `${data.entry}|${data.themes[0]}`;
|
|
40
|
+
const inlineBody = live.firstElementChild?.cloneNode(true) ?? null;
|
|
41
|
+
/** `step` 0 is the whole flow at once — the authoring frame, and what a view
|
|
42
|
+
* without a flow always shows. 1..N are the walked ones. */
|
|
43
|
+
const bodyFor = (v, th, step = 0) => {
|
|
44
|
+
const key = step ? `${v}|${th}|${step}` : `${v}|${th}`;
|
|
45
|
+
if (key === inlineKey)
|
|
46
|
+
return inlineBody?.cloneNode(true) ?? null;
|
|
47
|
+
const tpl = document.querySelector(`template[data-key="${CSS.escape(key)}"]`);
|
|
48
|
+
return tpl ? tpl.content.cloneNode(true) : null;
|
|
49
|
+
};
|
|
50
|
+
const scopeOf = (v) => data.views.find((x) => x.name === v)?.scope;
|
|
51
|
+
const boxOf = (el) => {
|
|
52
|
+
const r = el.getBoundingClientRect(), s = stage.getBoundingClientRect();
|
|
53
|
+
return { x: r.left - s.left, y: r.top - s.top, w: r.width, h: r.height };
|
|
54
|
+
};
|
|
55
|
+
function paintChrome() {
|
|
56
|
+
document.title = data.views.find((v) => v.name === view)?.title ?? document.title;
|
|
57
|
+
// The view tabs are the deck's one navigation surface — every view by
|
|
58
|
+
// name, the active one on a plate, exactly the SPA's picker. They replaced
|
|
59
|
+
// both the breadcrumb and the dots: the tabs already name where you are
|
|
60
|
+
// and where you can go, so two more spellings of the same facts were
|
|
61
|
+
// chrome without capability.
|
|
62
|
+
const tabs = document.querySelector("#sq-tabs");
|
|
63
|
+
if (tabs) {
|
|
64
|
+
tabs.replaceChildren();
|
|
65
|
+
for (const v of data.views) {
|
|
66
|
+
const b = document.createElement("button");
|
|
67
|
+
b.type = "button";
|
|
68
|
+
b.className = v.name === view ? "on" : "";
|
|
69
|
+
b.textContent = v.name;
|
|
70
|
+
b.title = v.title ?? v.name;
|
|
71
|
+
b.onclick = () => go(v.name);
|
|
72
|
+
tabs.append(b);
|
|
73
|
+
}
|
|
74
|
+
// keep the active tab reachable when the deck outgrows the bar
|
|
75
|
+
tabs.querySelector(".on")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
|
76
|
+
}
|
|
77
|
+
// Mark the cards that actually lead somewhere. Only the runtime can know:
|
|
78
|
+
// it depends on which views this file carries and which one you are in.
|
|
79
|
+
// Without it every card wore a zoom cursor and the ones with no view were
|
|
80
|
+
// a dead click — the bug this whole pass came from.
|
|
81
|
+
for (const el of live.querySelectorAll("[data-path]")) {
|
|
82
|
+
const p = el.getAttribute("data-path");
|
|
83
|
+
el.classList.toggle("sq-zoom", !!(p && viewForPath(data.views, view, p)));
|
|
84
|
+
}
|
|
85
|
+
const counter = document.querySelector("#sq-step");
|
|
86
|
+
const hops = data.flows[view] ?? 0;
|
|
87
|
+
if (counter)
|
|
88
|
+
counter.textContent = presenting && hops ? `${step || 1} / ${hops}` : "";
|
|
89
|
+
}
|
|
90
|
+
/** Swap the body, then animate the two layers about the card they share.
|
|
91
|
+
* The playground splits this in two ("arm, then fire") because compiling the
|
|
92
|
+
* next view is async; here every body is already in the document, so the
|
|
93
|
+
* swap is synchronous and the whole dance is one function. */
|
|
94
|
+
function go(target, enterAtEnd = false) {
|
|
95
|
+
if (!target || target === view)
|
|
96
|
+
return;
|
|
97
|
+
// A slide opens on its first hop — unless you reversed into it, in which
|
|
98
|
+
// case you arrive where you left and can keep unwinding.
|
|
99
|
+
const want = presenting && data.flows[target] ? (enterAtEnd ? data.flows[target] : 1) : 0;
|
|
100
|
+
const next = bodyFor(target, theme, want);
|
|
101
|
+
if (!next)
|
|
102
|
+
return;
|
|
103
|
+
step = want;
|
|
104
|
+
const { dir, anchor: anchorPath } = hop(data.views, view, target);
|
|
105
|
+
const ghostBox = boxOf(live);
|
|
106
|
+
const prev = live.firstElementChild;
|
|
107
|
+
if (reduced() || !prev) {
|
|
108
|
+
live.replaceChildren(next);
|
|
109
|
+
view = target;
|
|
110
|
+
paintChrome();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
ghost.replaceChildren(prev.cloneNode(true));
|
|
114
|
+
ghost.style.cssText =
|
|
115
|
+
`position:absolute;left:${ghostBox.x}px;top:${ghostBox.y}px;` +
|
|
116
|
+
`width:${ghostBox.w}px;height:${ghostBox.h}px;z-index:1;pointer-events:none`;
|
|
117
|
+
live.replaceChildren(next);
|
|
118
|
+
view = target;
|
|
119
|
+
paintChrome();
|
|
120
|
+
const liveBox = boxOf(live);
|
|
121
|
+
// Going down, the shared card is in the layer we just left; coming up it is
|
|
122
|
+
// in the one that just arrived. Both layers are on screen either way, which
|
|
123
|
+
// is why one lookup covers both.
|
|
124
|
+
const from = dir === "in" ? ghost : live;
|
|
125
|
+
const el = anchorPath ? from.querySelector(`[data-path="${CSS.escape(anchorPath)}"]`) : null;
|
|
126
|
+
const t = diveTransforms({
|
|
127
|
+
view: { x: 0, y: 0, w: stage.clientWidth, h: stage.clientHeight },
|
|
128
|
+
ghostBox, liveBox, anchor: el ? boxOf(el) : undefined, dir,
|
|
129
|
+
});
|
|
130
|
+
const g = ghost.style, l = live.style;
|
|
131
|
+
g.transition = "none";
|
|
132
|
+
g.transformOrigin = t.gOrigin;
|
|
133
|
+
g.transform = "none";
|
|
134
|
+
g.opacity = "1";
|
|
135
|
+
l.transition = "none";
|
|
136
|
+
l.transformOrigin = t.lOrigin;
|
|
137
|
+
l.transform = t.lStart;
|
|
138
|
+
l.opacity = "0";
|
|
139
|
+
void live.offsetHeight; // commit the start state before transitioning off it
|
|
140
|
+
g.transition = `transform ${t.ms}ms ${t.ease}, opacity ${Math.round(t.ms * 0.55)}ms ${t.ease}`;
|
|
141
|
+
l.transition =
|
|
142
|
+
`transform ${t.ms}ms ${t.ease}, opacity ${Math.round(t.ms * 0.6)}ms ${t.ease} ${Math.round(t.ms * 0.25)}ms`;
|
|
143
|
+
g.transform = t.gEnd;
|
|
144
|
+
g.opacity = "0";
|
|
145
|
+
l.transform = "none";
|
|
146
|
+
l.opacity = "1";
|
|
147
|
+
// Clean up when the dive actually lands, not on a wall-clock guess. The
|
|
148
|
+
// old timer fired t.ms after the *click*, but the transition cannot start
|
|
149
|
+
// until the browser paints the swapped-in body — a heavy first layout of
|
|
150
|
+
// a large view delayed that by hundreds of ms, the timer stripped the
|
|
151
|
+
// transition mid-flight, and the diagram visibly snapped into place a
|
|
152
|
+
// beat after the zoom. Watch the observable fact instead of an event:
|
|
153
|
+
// live's computed transform reads as interpolated matrices while the
|
|
154
|
+
// transition runs and becomes the literal "none" only when it has truly
|
|
155
|
+
// finished — one cheap read per frame for under half a second, immune to
|
|
156
|
+
// the transitionend quirks headless engines showed when this was
|
|
157
|
+
// event-driven. The timer is only a net for a hidden tab, where rAF
|
|
158
|
+
// stops; by then the motion is long over and cleanup is invisible.
|
|
159
|
+
let settled = false;
|
|
160
|
+
let raf = 0;
|
|
161
|
+
const settle = () => {
|
|
162
|
+
if (settled)
|
|
163
|
+
return;
|
|
164
|
+
settled = true;
|
|
165
|
+
cancelAnimationFrame(raf);
|
|
166
|
+
ghost.replaceChildren();
|
|
167
|
+
ghost.removeAttribute("style");
|
|
168
|
+
live.removeAttribute("style");
|
|
169
|
+
};
|
|
170
|
+
const watch = () => {
|
|
171
|
+
if (getComputedStyle(live).transform === "none")
|
|
172
|
+
settle();
|
|
173
|
+
else
|
|
174
|
+
raf = requestAnimationFrame(watch);
|
|
175
|
+
};
|
|
176
|
+
raf = requestAnimationFrame(watch);
|
|
177
|
+
setTimeout(settle, t.ms + 1000);
|
|
178
|
+
}
|
|
179
|
+
function setTheme(name) {
|
|
180
|
+
const body = name === theme ? null : bodyFor(view, name, step);
|
|
181
|
+
if (!body)
|
|
182
|
+
return;
|
|
183
|
+
theme = name;
|
|
184
|
+
document.documentElement.dataset.theme = name;
|
|
185
|
+
live.replaceChildren(body);
|
|
186
|
+
}
|
|
187
|
+
/** Walking a flow and walking the deck are one axis: keep stepping hops
|
|
188
|
+
* until the story runs out, then move to the next view. The playground
|
|
189
|
+
* merges them the same way, and for the same reason — one arrow key tells
|
|
190
|
+
* the whole story rather than two that each tell half. */
|
|
191
|
+
function step_(by) {
|
|
192
|
+
const hops = data.flows[view] ?? 0;
|
|
193
|
+
const next = step + by;
|
|
194
|
+
if (hops && next >= 1 && next <= hops) {
|
|
195
|
+
const body = bodyFor(view, theme, next);
|
|
196
|
+
if (body) {
|
|
197
|
+
step = next;
|
|
198
|
+
live.replaceChildren(body);
|
|
199
|
+
paintChrome();
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const at = data.views.findIndex((v) => v.name === view);
|
|
204
|
+
const to = data.views[at + by];
|
|
205
|
+
if (to)
|
|
206
|
+
go(to.name, by < 0);
|
|
207
|
+
}
|
|
208
|
+
live.addEventListener("click", (e) => {
|
|
209
|
+
const el = e.target.closest?.("[data-path]");
|
|
210
|
+
const path = el?.getAttribute("data-path");
|
|
211
|
+
if (path) {
|
|
212
|
+
const target = viewForPath(data.views, view, path);
|
|
213
|
+
if (target)
|
|
214
|
+
return go(target.name);
|
|
215
|
+
}
|
|
216
|
+
// clicking the canvas itself climbs, which is the gesture the playground
|
|
217
|
+
// gives the backdrop
|
|
218
|
+
if (!path) {
|
|
219
|
+
const up = upView(data.views, view, scopeOf(view));
|
|
220
|
+
if (up)
|
|
221
|
+
go(up);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
const themeBtn = document.querySelector("#sq-theme");
|
|
225
|
+
if (themeBtn)
|
|
226
|
+
themeBtn.onclick = () => setTheme(data.themes[(data.themes.indexOf(theme) + 1) % data.themes.length]);
|
|
227
|
+
/** Full-bleed deck. The declared views in declaration order *are* the slides —
|
|
228
|
+
* nothing is authored twice (DESIGN §11). Fullscreen is requested on the
|
|
229
|
+
* gesture that turns it on, which is also the only time a browser allows it;
|
|
230
|
+
* the playground can ask on mount because entering is already a gesture
|
|
231
|
+
* there, but a file opens as a document. */
|
|
232
|
+
function present(on) {
|
|
233
|
+
if (on === presenting)
|
|
234
|
+
return;
|
|
235
|
+
presenting = on;
|
|
236
|
+
document.body.classList.toggle("presenting", on);
|
|
237
|
+
if (on) {
|
|
238
|
+
document.documentElement.requestFullscreen?.().catch(() => { });
|
|
239
|
+
const hops = data.flows[view] ?? 0;
|
|
240
|
+
if (hops && !step) {
|
|
241
|
+
const body = bodyFor(view, theme, 1);
|
|
242
|
+
if (body) {
|
|
243
|
+
step = 1;
|
|
244
|
+
live.replaceChildren(body);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
if (document.fullscreenElement)
|
|
250
|
+
document.exitFullscreen?.().catch(() => { });
|
|
251
|
+
if (step) {
|
|
252
|
+
const body = bodyFor(view, theme, 0);
|
|
253
|
+
if (body) {
|
|
254
|
+
step = 0;
|
|
255
|
+
live.replaceChildren(body);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
paintChrome();
|
|
260
|
+
}
|
|
261
|
+
// Chrome gets out of the way while nothing is happening, and comes back on
|
|
262
|
+
// the first movement — a deck that keeps its furniture on screen is a
|
|
263
|
+
// screenshot of an app, not a presentation.
|
|
264
|
+
let idle;
|
|
265
|
+
const wake = () => {
|
|
266
|
+
document.body.classList.remove("idle");
|
|
267
|
+
clearTimeout(idle);
|
|
268
|
+
if (presenting)
|
|
269
|
+
idle = setTimeout(() => document.body.classList.add("idle"), 3500);
|
|
270
|
+
};
|
|
271
|
+
addEventListener("mousemove", wake);
|
|
272
|
+
addEventListener("keydown", wake);
|
|
273
|
+
const cycleTheme = () => setTheme(data.themes[(data.themes.indexOf(theme) + 1) % data.themes.length]);
|
|
274
|
+
addEventListener("keydown", (e) => {
|
|
275
|
+
if (e.metaKey || e.ctrlKey || e.altKey)
|
|
276
|
+
return;
|
|
277
|
+
switch (e.key) {
|
|
278
|
+
case "ArrowRight":
|
|
279
|
+
case "PageDown":
|
|
280
|
+
case " ":
|
|
281
|
+
case "Enter":
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
step_(1);
|
|
284
|
+
break;
|
|
285
|
+
case "ArrowLeft":
|
|
286
|
+
case "PageUp":
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
step_(-1);
|
|
289
|
+
break;
|
|
290
|
+
// climbing an altitude is a *different* move from stepping the deck, and
|
|
291
|
+
// gets its own keys
|
|
292
|
+
case "ArrowUp":
|
|
293
|
+
case "Backspace": {
|
|
294
|
+
const up = upView(data.views, view, scopeOf(view));
|
|
295
|
+
if (up) {
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
go(up);
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "Home":
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
go(data.views[0].name);
|
|
304
|
+
break;
|
|
305
|
+
case "End":
|
|
306
|
+
e.preventDefault();
|
|
307
|
+
go(data.views[data.views.length - 1].name, true);
|
|
308
|
+
break;
|
|
309
|
+
case "Escape":
|
|
310
|
+
if (presenting) {
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
present(false);
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
case "p":
|
|
316
|
+
case "P":
|
|
317
|
+
present(!presenting);
|
|
318
|
+
break;
|
|
319
|
+
case "f":
|
|
320
|
+
case "F":
|
|
321
|
+
if (document.fullscreenElement)
|
|
322
|
+
document.exitFullscreen?.().catch(() => { });
|
|
323
|
+
else
|
|
324
|
+
document.documentElement.requestFullscreen?.().catch(() => { });
|
|
325
|
+
break;
|
|
326
|
+
case "t":
|
|
327
|
+
case "T":
|
|
328
|
+
if (data.themes.length > 1)
|
|
329
|
+
cycleTheme();
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
// leaving fullscreen by the browser's own affordance should leave the deck
|
|
334
|
+
addEventListener("fullscreenchange", () => {
|
|
335
|
+
if (!document.fullscreenElement && presenting)
|
|
336
|
+
present(false);
|
|
337
|
+
});
|
|
338
|
+
const presentBtn = document.querySelector("#sq-present");
|
|
339
|
+
if (presentBtn)
|
|
340
|
+
presentBtn.onclick = () => present(!presenting);
|
|
341
|
+
// A deep link opens on that view — what makes the file shareable by more than
|
|
342
|
+
// its filename. Kept in sync so the reader's back button and a copied URL
|
|
343
|
+
// both do what they look like they do.
|
|
344
|
+
const fromHash = () => {
|
|
345
|
+
const want = decodeURIComponent(location.hash.slice(1));
|
|
346
|
+
if (want && want !== view && data.views.some((v) => v.name === want))
|
|
347
|
+
go(want);
|
|
348
|
+
};
|
|
349
|
+
addEventListener("hashchange", fromHash);
|
|
350
|
+
document.documentElement.dataset.theme = theme;
|
|
351
|
+
if (theme !== data.themes[0]) {
|
|
352
|
+
const body = bodyFor(view, theme);
|
|
353
|
+
if (body)
|
|
354
|
+
live.replaceChildren(body);
|
|
355
|
+
}
|
|
356
|
+
paintChrome();
|
|
357
|
+
fromHash();
|
|
358
|
+
}
|
|
359
|
+
if (document.readyState === "loading")
|
|
360
|
+
addEventListener("DOMContentLoaded", boot);
|
|
361
|
+
else
|
|
362
|
+
boot();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type ProjectFile } from "../model/build.js";
|
|
2
|
+
import type { Diagnostic } from "../model/types.js";
|
|
3
|
+
export interface HTMLExportOpts {
|
|
4
|
+
/** Which view opens. Default: the first declared one. */
|
|
5
|
+
view?: string;
|
|
6
|
+
/** Palettes to bundle; the first is the entry. Default: the project's theme
|
|
7
|
+
* and its `pairsWith` counterpart, so the file follows the reader's OS. */
|
|
8
|
+
themes?: string[];
|
|
9
|
+
/** `all` (default) includes the auto view every container gets, so every card
|
|
10
|
+
* that zooms in the playground zooms here too. `declared` trims it to the
|
|
11
|
+
* views an author wrote, which is smaller but leaves any container without
|
|
12
|
+
* one as a dead card — it was the default until a `Storefront` card in
|
|
13
|
+
* `examples/microservices` did nothing when clicked, because it is the one
|
|
14
|
+
* system there with no declared view of its own. `--sync` makes the opposite
|
|
15
|
+
* call for a good reason that does not apply here: an auto view costs it a
|
|
16
|
+
* *file* nobody asked for, where it costs this a few KB. */
|
|
17
|
+
views?: "declared" | "all";
|
|
18
|
+
/** Document title. Default: the entry view's title, or the project name. */
|
|
19
|
+
title?: string;
|
|
20
|
+
/** Pre-render one frame per hop of a `show flow`, so presentation mode can
|
|
21
|
+
* walk the story. Default true; only views that declare a flow cost
|
|
22
|
+
* anything, and a frame is the same 3–9 KB as any other body. */
|
|
23
|
+
flowSteps?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface HTMLExportResult {
|
|
26
|
+
html?: string;
|
|
27
|
+
diagnostics: Diagnostic[];
|
|
28
|
+
ok: boolean;
|
|
29
|
+
/** What went in — the CLI prints it, the tests assert on it. */
|
|
30
|
+
manifest: {
|
|
31
|
+
views: string[];
|
|
32
|
+
themes: string[];
|
|
33
|
+
renders: number;
|
|
34
|
+
bytes: number;
|
|
35
|
+
/** view name → hops that render at that altitude, for the ones with a flow */
|
|
36
|
+
flows: Record<string, number>;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export declare function exportHTML(files: ProjectFile[], opts?: HTMLExportOpts): Promise<HTMLExportResult>;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// One self-contained HTML file carrying every view of a project, and a viewer
|
|
2
|
+
// that dives between them.
|
|
3
|
+
//
|
|
4
|
+
// Squinch's whole idea is that views are altitudes over one model and that
|
|
5
|
+
// moving between them is navigation (DESIGN §11). Until this, that experience
|
|
6
|
+
// existed only in the playground: everything you could hand someone else — an
|
|
7
|
+
// SVG, a PNG, an adaptive pair — was one frozen altitude. This is the artifact
|
|
8
|
+
// that travels.
|
|
9
|
+
//
|
|
10
|
+
// THE EXCEPTION, STATED. CLAUDE.md's rule is that an exported *SVG* never
|
|
11
|
+
// contains JS. That still holds without qualification, including for the SVGs
|
|
12
|
+
// in here: each one is what `render -o x.svg` produces (minus the defs this
|
|
13
|
+
// document shares), and the script is their sibling, never their content. The
|
|
14
|
+
// entry view is inline rather than in a <template>, so a reader whose browser
|
|
15
|
+
// or wiki sanitizer drops the script still gets a correct static diagram.
|
|
16
|
+
//
|
|
17
|
+
// WHY IT IS SMALL. Every view repeats the same two things: ~33 KB of base64
|
|
18
|
+
// font and 17–27 KB of icon <symbol>s. Fragment references resolve
|
|
19
|
+
// document-wide in HTML, so one definition serves all of them — `collectDefs`
|
|
20
|
+
// hands them out instead of emitting them, and they land once in a hidden
|
|
21
|
+
// sprite. Measured on `examples/microservices`: six views go from 362 KB
|
|
22
|
+
// concatenated to ~105 KB. The actual drawing is 3–9 KB per view, which is the
|
|
23
|
+
// right shape for the file to have.
|
|
24
|
+
import { buildProject } from "../model/build.js";
|
|
25
|
+
import { themes } from "../themes/index.js";
|
|
26
|
+
import { fontFaceCSS, allFaces } from "./svg.js";
|
|
27
|
+
import { RUNTIME_JS } from "./html/runtime.generated.js";
|
|
28
|
+
const EMPTY = { views: [], themes: [], renders: 0, bytes: 0, flows: {} };
|
|
29
|
+
/** `</script>` inside a JSON island would end the island. Escaping the `<`
|
|
30
|
+
* keeps the payload inert wherever it lands. */
|
|
31
|
+
const jsonIsland = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
32
|
+
const attr = (s) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
33
|
+
const text = (s) => s.replace(/&/g, "&").replace(/</g, "<");
|
|
34
|
+
export async function exportHTML(files, opts = {}) {
|
|
35
|
+
// imported lazily: html.ts is part of the render layer and api.ts imports it,
|
|
36
|
+
// so taking renderProject the other way round would be a cycle
|
|
37
|
+
const { renderProject } = await import("../api.js");
|
|
38
|
+
const built = buildProject(files);
|
|
39
|
+
if (!built.ok)
|
|
40
|
+
return { diagnostics: built.diagnostics, ok: false, manifest: EMPTY };
|
|
41
|
+
const all = built.model.views;
|
|
42
|
+
const list = (opts.views === "declared" ? all.filter((v) => !v.auto) : all).map((v) => ({ name: v.name, scope: v.scope, title: v.title, auto: v.auto }));
|
|
43
|
+
if (!list.length)
|
|
44
|
+
return {
|
|
45
|
+
diagnostics: [...built.diagnostics, {
|
|
46
|
+
severity: "error",
|
|
47
|
+
message: "nothing to export — this project declares no views",
|
|
48
|
+
fix: "add a `view` block, or pass views: \"all\" to include the automatic ones",
|
|
49
|
+
loc: { from: 0, to: 0, line: 1, col: 1 },
|
|
50
|
+
}],
|
|
51
|
+
ok: false,
|
|
52
|
+
manifest: EMPTY,
|
|
53
|
+
};
|
|
54
|
+
const entry = opts.view ?? list[0].name;
|
|
55
|
+
if (!list.some((v) => v.name === entry))
|
|
56
|
+
return {
|
|
57
|
+
diagnostics: [...built.diagnostics, {
|
|
58
|
+
severity: "error",
|
|
59
|
+
message: `unknown view \`${entry}\``,
|
|
60
|
+
fix: `views in this export: ${list.map((v) => v.name).join(", ")}`,
|
|
61
|
+
loc: { from: 0, to: 0, line: 1, col: 1 },
|
|
62
|
+
}],
|
|
63
|
+
ok: false,
|
|
64
|
+
manifest: EMPTY,
|
|
65
|
+
};
|
|
66
|
+
// A document has one palette at a time and a button to change it, so the
|
|
67
|
+
// theme is the document's rather than each view's — a per-view `theme` is
|
|
68
|
+
// deliberately overridden here, the way `--theme` overrides it elsewhere.
|
|
69
|
+
const base = opts.themes?.[0] ?? built.model.fileTheme ?? "light";
|
|
70
|
+
const palette = opts.themes ?? [base, themes[base]?.pairsWith].filter(Boolean);
|
|
71
|
+
for (const name of palette)
|
|
72
|
+
if (!themes[name])
|
|
73
|
+
return {
|
|
74
|
+
diagnostics: [...built.diagnostics, {
|
|
75
|
+
severity: "error",
|
|
76
|
+
message: `unknown theme \`${name}\``,
|
|
77
|
+
fix: `available: ${Object.keys(themes).join(", ")}`,
|
|
78
|
+
loc: { from: 0, to: 0, line: 1, col: 1 },
|
|
79
|
+
}],
|
|
80
|
+
ok: false,
|
|
81
|
+
manifest: EMPTY,
|
|
82
|
+
};
|
|
83
|
+
const defs = new Map();
|
|
84
|
+
const bodies = new Map();
|
|
85
|
+
const flows = {};
|
|
86
|
+
const diagnostics = [...built.diagnostics];
|
|
87
|
+
const draw = async (view, th, flowStep) => {
|
|
88
|
+
const r = await renderProject(files, {
|
|
89
|
+
view, theme: th, embedFonts: false, collectDefs: defs,
|
|
90
|
+
// the one theme-dependent def (`sq-hatch`) needs a distinct id per
|
|
91
|
+
// palette, or a dark view would draw with the light texture
|
|
92
|
+
defsScope: `-${th}`,
|
|
93
|
+
...(flowStep === undefined ? {} : { flowStep }),
|
|
94
|
+
});
|
|
95
|
+
diagnostics.push(...r.diagnostics.filter((d) => d.severity === "error"));
|
|
96
|
+
return r;
|
|
97
|
+
};
|
|
98
|
+
for (const th of palette)
|
|
99
|
+
for (const v of list) {
|
|
100
|
+
// the whole flow at once is the authoring frame, and the one the file
|
|
101
|
+
// opens on; the walked frames are extra
|
|
102
|
+
const r = await draw(v.name, th);
|
|
103
|
+
if (!r.ok || !r.svg)
|
|
104
|
+
return { diagnostics, ok: false, manifest: EMPTY };
|
|
105
|
+
bodies.set(`${v.name}|${th}`, r.svg.trim());
|
|
106
|
+
// `flow.steps` counts hops that render *at this altitude*, which is
|
|
107
|
+
// exactly the number of frames a presenter can reach
|
|
108
|
+
if (!r.flow || opts.flowSteps === false)
|
|
109
|
+
continue;
|
|
110
|
+
flows[v.name] = r.flow.steps;
|
|
111
|
+
for (let step = 1; step <= r.flow.steps; step++) {
|
|
112
|
+
const f = await draw(v.name, th, step);
|
|
113
|
+
if (!f.ok || !f.svg)
|
|
114
|
+
return { diagnostics, ok: false, manifest: EMPTY };
|
|
115
|
+
bodies.set(`${v.name}|${th}|${step}`, f.svg.trim());
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Every face, not the union of what these views happened to draw: the
|
|
119
|
+
// viewer swaps views (and palettes) client-side from bodies it already
|
|
120
|
+
// holds, so a set trimmed to the entry view would leave a later one's title
|
|
121
|
+
// block falling back to a face with different metrics.
|
|
122
|
+
const fonts = [...new Set(palette.map((n) => fontFaceCSS(themes[n], allFaces())))].join("");
|
|
123
|
+
const sprite = [...defs.keys()].sort().map((k) => defs.get(k)).join("");
|
|
124
|
+
const title = opts.title ?? list.find((v) => v.name === entry)?.title ?? files[0]?.name.replace(/\.squinch$/, "") ?? "diagram";
|
|
125
|
+
const html = document({
|
|
126
|
+
title,
|
|
127
|
+
fonts,
|
|
128
|
+
sprite,
|
|
129
|
+
palette: palette.map((n) => themes[n]),
|
|
130
|
+
entry,
|
|
131
|
+
entryBody: bodies.get(`${entry}|${palette[0]}`),
|
|
132
|
+
bodies,
|
|
133
|
+
views: list,
|
|
134
|
+
flows,
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
html,
|
|
138
|
+
diagnostics,
|
|
139
|
+
ok: true,
|
|
140
|
+
manifest: {
|
|
141
|
+
views: list.map((v) => v.name),
|
|
142
|
+
themes: palette,
|
|
143
|
+
renders: bodies.size,
|
|
144
|
+
bytes: html.length,
|
|
145
|
+
flows,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function document(a) {
|
|
150
|
+
// chrome colours come from the same theme tokens the diagram draws with, so
|
|
151
|
+
// the frame around a dark diagram is dark (DESIGN §10)
|
|
152
|
+
const vars = (t, sel) => `${sel}{--sq-canvas:${t.canvas};--sq-ink:${t.ink};--sq-muted:${t.muted};--sq-border:${t.border};--sq-surface:${t.surface}}`;
|
|
153
|
+
const themeVars = a.palette
|
|
154
|
+
.map((t, i) => vars(t, i === 0 ? ":root,:root[data-theme=\"" + t.name + "\"]" : `:root[data-theme="${t.name}"]`))
|
|
155
|
+
.join("");
|
|
156
|
+
const L = [];
|
|
157
|
+
L.push("<!doctype html>");
|
|
158
|
+
L.push(`<html lang="en" data-theme="${attr(a.palette[0].name)}">`);
|
|
159
|
+
L.push("<head>");
|
|
160
|
+
L.push('<meta charset="utf-8">');
|
|
161
|
+
L.push('<meta name="viewport" content="width=device-width,initial-scale=1">');
|
|
162
|
+
L.push(`<title>${text(a.title)}</title>`);
|
|
163
|
+
L.push(`<style>${a.fonts}${themeVars}${CHROME_CSS}</style>`);
|
|
164
|
+
L.push("</head>");
|
|
165
|
+
L.push("<body>");
|
|
166
|
+
// never display:none — some engines stop resolving <use> into a hidden tree
|
|
167
|
+
L.push(`<svg id="sq-defs" aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">` +
|
|
168
|
+
`<defs>${a.sprite}</defs></svg>`);
|
|
169
|
+
// No breadcrumb: the view tabs in the footer name where you are and where
|
|
170
|
+
// you can go, so the header keeps only the document-level controls.
|
|
171
|
+
L.push('<header id="sq-bar"><span id="sq-step"></span>' +
|
|
172
|
+
(a.palette.length > 1 ? '<button id="sq-theme" type="button" title="Change palette (t)">◐</button>' : "") +
|
|
173
|
+
(a.views.length > 1 ? '<button id="sq-present" type="button" title="Present (p)">Present</button>' : "") +
|
|
174
|
+
"</header>");
|
|
175
|
+
L.push('<main id="sq-stage"><div id="sq-ghost" aria-hidden="true"></div><div id="sq-live">');
|
|
176
|
+
L.push(a.entryBody);
|
|
177
|
+
L.push("</div></main>");
|
|
178
|
+
if (a.views.length > 1)
|
|
179
|
+
L.push('<footer id="sq-foot"><nav id="sq-tabs" aria-label="Views"></nav></footer>');
|
|
180
|
+
for (const [key, svg] of a.bodies) {
|
|
181
|
+
if (key === `${a.entry}|${a.palette[0].name}`)
|
|
182
|
+
continue; // already inline
|
|
183
|
+
L.push(`<template data-key="${attr(key)}">${svg}</template>`);
|
|
184
|
+
}
|
|
185
|
+
L.push(`<script type="application/json" id="sq-data">` +
|
|
186
|
+
jsonIsland({
|
|
187
|
+
views: a.views, entry: a.entry, themes: a.palette.map((t) => t.name), flows: a.flows,
|
|
188
|
+
}) +
|
|
189
|
+
`</script>`);
|
|
190
|
+
L.push(`<script>${RUNTIME_JS}</script>`);
|
|
191
|
+
L.push("</body>");
|
|
192
|
+
L.push("</html>");
|
|
193
|
+
return L.join("\n") + "\n";
|
|
194
|
+
}
|
|
195
|
+
const CHROME_CSS = "*{box-sizing:border-box}" +
|
|
196
|
+
"body{margin:0;background:var(--sq-canvas);color:var(--sq-ink);" +
|
|
197
|
+
"font:13px/1.5 system-ui,-apple-system,sans-serif;height:100vh;display:flex;flex-direction:column}" +
|
|
198
|
+
"#sq-bar{display:flex;align-items:center;justify-content:flex-end;gap:12px;padding:10px 16px;flex:none}" +
|
|
199
|
+
"#sq-theme{font:inherit;background:var(--sq-surface);color:var(--sq-muted);cursor:pointer;" +
|
|
200
|
+
"border:1px solid var(--sq-border);border-radius:6px;padding:2px 8px;flex:none}" +
|
|
201
|
+
"#sq-stage{position:relative;flex:1;min-height:0;overflow:auto;display:grid;place-items:center}" +
|
|
202
|
+
// out of flow ALWAYS, not just while a dive's inline styles are on it. As a
|
|
203
|
+
// grid child the empty ghost owned a row, and default align-content:normal
|
|
204
|
+
// stretches auto rows — so the moment cleanup returned it to flow, the live
|
|
205
|
+
// layer's row moved down and the whole diagram jumped a beat after the
|
|
206
|
+
// zoom landed. The dive sets left/top/size inline per flight; this base
|
|
207
|
+
// rule is what holds between flights.
|
|
208
|
+
"#sq-ghost{position:absolute;left:0;top:0;pointer-events:none}" +
|
|
209
|
+
"#sq-live svg,#sq-ghost svg{max-width:100%;height:auto;display:block}" +
|
|
210
|
+
// a card that leads somewhere says so — and only one that does. The class is
|
|
211
|
+
// applied by the runtime, which is the only thing that knows whether a path
|
|
212
|
+
// resolves to a view; styling every card invited a click that did nothing.
|
|
213
|
+
"#sq-live .sq-zoom{cursor:zoom-in}" +
|
|
214
|
+
"#sq-step{color:var(--sq-muted);font-variant-numeric:tabular-nums;flex:none}" +
|
|
215
|
+
"#sq-present{font:inherit;background:var(--sq-surface);color:var(--sq-muted);cursor:pointer;" +
|
|
216
|
+
"border:1px solid var(--sq-border);border-radius:6px;padding:2px 10px;flex:none}" +
|
|
217
|
+
"#sq-foot{display:flex;justify-content:center;padding:10px;flex:none;min-width:0}" +
|
|
218
|
+
// the SPA's view picker, verbatim in spirit: a quiet bar of view names, the
|
|
219
|
+
// active one on a plate. Scrolls sideways rather than wrapping when a deck
|
|
220
|
+
// outgrows it — a two-row bar reads as two bars.
|
|
221
|
+
"#sq-tabs{display:flex;gap:2px;padding:3px;max-width:calc(100% - 16px);overflow-x:auto;" +
|
|
222
|
+
"border:1px solid var(--sq-border);border-radius:8px;background:var(--sq-surface)}" +
|
|
223
|
+
"#sq-tabs button{font:inherit;font-size:12px;white-space:nowrap;padding:3px 10px;border-radius:5px;" +
|
|
224
|
+
"border:0;background:none;color:var(--sq-muted);cursor:pointer}" +
|
|
225
|
+
"#sq-tabs button:hover{color:var(--sq-ink)}" +
|
|
226
|
+
"#sq-tabs button.on{background:var(--sq-canvas);color:var(--sq-ink);" +
|
|
227
|
+
"box-shadow:inset 0 0 0 1px var(--sq-border)}" +
|
|
228
|
+
// presenting: full bleed, chrome floats over the canvas and fades when idle
|
|
229
|
+
"body.presenting #sq-bar,body.presenting #sq-foot{position:fixed;left:0;right:0;z-index:2;" +
|
|
230
|
+
"transition:opacity .3s;background:transparent}" +
|
|
231
|
+
"body.presenting #sq-bar{top:0}body.presenting #sq-foot{bottom:0}" +
|
|
232
|
+
"body.presenting #sq-stage{padding:0}" +
|
|
233
|
+
"body.presenting.idle #sq-bar,body.presenting.idle #sq-foot{opacity:0;pointer-events:none}" +
|
|
234
|
+
"body.presenting.idle{cursor:none}" +
|
|
235
|
+
"@media (prefers-reduced-motion:reduce){#sq-live,#sq-ghost{transition:none!important}}";
|