@geml/geml 1.4.6 → 1.5.1

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/dist/render.js CHANGED
@@ -11,12 +11,19 @@
11
11
  // exception. They load from a CDN, and only when the document actually uses them,
12
12
  // so a document of prose, tables and charts is fully self-contained with zero
13
13
  // network. Bundling those two engines offline is the next step (roadmap P0 #6).
14
+ import { projectableInlines } from "./geml.js";
15
+ import { isSafeUrl } from "./inline.js";
14
16
  const PALETTE = ["#2563eb", "#dc2626", "#059669", "#d97706", "#7c3aed", "#db2777", "#0891b2", "#ea580c"];
15
17
  // ---------------------------------------------------------------------------
16
18
  // Escaping
17
19
  // ---------------------------------------------------------------------------
18
20
  export function esc(s) {
19
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
21
+ // C0 controls other than tab/LF/CR are not valid in HTML text and, passed
22
+ // through verbatim, can desynchronize a downstream sanitizer, proxy or log
23
+ // pipeline. §0.4 only normalizes NUL; a document can still carry the rest, and a
24
+ // transclusion of a `.geml`-named binary carries a lot of them.
25
+ return s.replace(/[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "�")
26
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
20
27
  }
21
28
  export function escAttr(s) {
22
29
  return esc(s).replace(/"/g, "&quot;");
@@ -39,6 +46,97 @@ const MAX_NESTING = 256;
39
46
  // ---------------------------------------------------------------------------
40
47
  // Render context
41
48
  // ---------------------------------------------------------------------------
49
+ // S5: how deep transclusions may nest before the renderer stops expanding and
50
+ // degrades to the reference link instead.
51
+ const EMBED_DEPTH_CAP = 8;
52
+ // Depth and cycle detection bound the SHAPE of a transclusion graph, never its
53
+ // total. A diamond is not a cycle, and the cycle key is `path#fragment`, so eight
54
+ // sections each embedding the next N times is eight distinct keys and N^8
55
+ // expansions: 1.5KB of input reached 402MB of output, and one step further died on
56
+ // an uncaught RangeError from string concatenation. These are the global budgets
57
+ // that actually bound it, checked before every expansion.
58
+ const EMBED_TOTAL_CAP = 1000; // expansions per render
59
+ const EMBED_BYTES_CAP = 8 * 1024 * 1024; // expanded bytes per render
60
+ const EMBED_DOC_BYTES_CAP = 4 * 1024 * 1024; // a single loaded document
61
+ // §9.5 requires a class token to be REDUCED to the identifier charset, not escaped
62
+ // — escaping keeps whatever was there. Only literals reach these call sites today,
63
+ // so this is about not letting that invariant rest on the caller.
64
+ function classAttrToken(s) {
65
+ return s.replace(/[^A-Za-z0-9_-]/g, "-");
66
+ }
67
+ // Compose a target that is relative to `base` — itself relative to the rendered
68
+ // host file — into a path relative to that host. Pure string work on purpose:
69
+ // render.ts is bundled for the browser (the playground), so no node:path here.
70
+ // A scheme-bearing, protocol-relative or root-relative target is already
71
+ // absolute and passes through untouched.
72
+ function relJoin(base, target) {
73
+ if (base === "" || target === "" || target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target))
74
+ return target;
75
+ const out = [];
76
+ for (const s of (base + "/" + target).split("/")) {
77
+ if (s === "" || s === ".")
78
+ continue;
79
+ if (s === ".." && out.length > 0 && out[out.length - 1] !== "..")
80
+ out.pop();
81
+ else
82
+ out.push(s);
83
+ }
84
+ return out.join("/");
85
+ }
86
+ function relDir(p) {
87
+ const i = p.lastIndexOf("/");
88
+ return i < 0 ? "" : p.slice(0, i);
89
+ }
90
+ // S2: which blocks a fragment selects. No fragment is the whole document body
91
+ // (meta is frontmatter, not content). A heading id selects its whole SECTION —
92
+ // the heading plus everything up to the next heading at the same or a higher
93
+ // level, the same boundary `geml get` uses — and any other id selects its own
94
+ // block. Nested children are searched too: an id can live inside a flow block.
95
+ function selectEmbed(children, anchor) {
96
+ if (anchor === undefined)
97
+ return children.filter((b) => !(b.kind === "block" && b.type === "meta"));
98
+ return findEmbedTarget(children, anchor);
99
+ }
100
+ function findEmbedTarget(blocks, id) {
101
+ for (let i = 0; i < blocks.length; i++) {
102
+ const b = blocks[i];
103
+ if (b.kind === "heading" && b.id === id) {
104
+ const out = [b];
105
+ for (let j = i + 1; j < blocks.length; j++) {
106
+ const next = blocks[j];
107
+ if (next.kind === "heading" && next.level <= b.level)
108
+ break;
109
+ out.push(next);
110
+ }
111
+ return out;
112
+ }
113
+ if (b.kind === "block" && b.id === id)
114
+ return [b];
115
+ if (b.kind === "block" && b.children) {
116
+ const inner = findEmbedTarget(b.children, id);
117
+ if (inner !== null)
118
+ return inner;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ // id -> label: a heading's text, a block's caption, else the id itself. Free of
124
+ // the render context so a TARGET document can be indexed the same way, which is
125
+ // what a cross-document auto-reference needs for its link text (§5.2).
126
+ function indexLabelsInto(blocks, into) {
127
+ for (const b of blocks) {
128
+ if (b.kind === "heading")
129
+ into.set(b.id ?? "", b.text);
130
+ else if (b.kind === "block") {
131
+ if (b.id) {
132
+ const cap = b.attrs["caption"];
133
+ into.set(b.id, typeof cap === "string" ? cap : (b.table?.caption ?? b.id));
134
+ }
135
+ if (b.children)
136
+ indexLabelsInto(b.children, into);
137
+ }
138
+ }
139
+ }
42
140
  export class RenderCtx {
43
141
  doc;
44
142
  opts;
@@ -46,6 +144,40 @@ export class RenderCtx {
46
144
  usedMermaid = false;
47
145
  usedCodeGraph = false;
48
146
  renderDepth = 0;
147
+ // S5: the (path#fragment) chain currently being expanded, for cycle
148
+ // detection and the depth cap. `embedDocs` is the chain of documents being
149
+ // expanded — each with its path relative to the host, so relative targets inside
150
+ // borrowed content compose through it (S4) and a fragment-only reference
151
+ // resolves against the document it was written in.
152
+ embedStack = [];
153
+ embedDocs = [];
154
+ // The global budgets, and a memo so a document is read and parsed at most once
155
+ // per render — without it a 1.1KB corpus produced 21,845 filesystem reads and
156
+ // 21,845 full re-parses, because every expansion loaded its target again.
157
+ embedCount = 0;
158
+ embedBytes = 0;
159
+ embedCache = new Map();
160
+ budgetExhausted() {
161
+ if (this.embedCount >= EMBED_TOTAL_CAP)
162
+ return `transclusion budget spent (${EMBED_TOTAL_CAP} expansions)`;
163
+ if (this.embedBytes >= EMBED_BYTES_CAP)
164
+ return `transclusion budget spent (${EMBED_BYTES_CAP} bytes)`;
165
+ return null;
166
+ }
167
+ // One read and one parse per document per render, and a size ceiling so a huge
168
+ // target cannot be expanded (or re-expanded) at all.
169
+ loadChildren(rel) {
170
+ const hit = this.embedCache.get(rel);
171
+ if (hit !== undefined)
172
+ return hit;
173
+ const { loadDoc, parseDoc } = this.opts;
174
+ let children = null;
175
+ const src = loadDoc && parseDoc ? loadDoc(rel) : null;
176
+ if (src !== null && src !== undefined && src.length <= EMBED_DOC_BYTES_CAP)
177
+ children = parseDoc(src).children;
178
+ this.embedCache.set(rel, children);
179
+ return children;
180
+ }
49
181
  labels = new Map(); // id -> link label for [[#id]] auto-refs
50
182
  constructor(doc, opts = {}) {
51
183
  this.doc = doc;
@@ -66,18 +198,7 @@ export class RenderCtx {
66
198
  }
67
199
  // Build the id -> label map: a heading's text, or a block's caption, or its id.
68
200
  indexLabels(blocks) {
69
- for (const b of blocks) {
70
- if (b.kind === "heading")
71
- this.labels.set(b.id ?? "", b.text);
72
- else if (b.kind === "block") {
73
- if (b.id) {
74
- const cap = b.attrs["caption"];
75
- this.labels.set(b.id, typeof cap === "string" ? cap : (b.table?.caption ?? b.id));
76
- }
77
- if (b.children)
78
- this.indexLabels(b.children);
79
- }
80
- }
201
+ indexLabelsInto(blocks, this.labels);
81
202
  }
82
203
  docTitle() {
83
204
  for (const b of this.doc.children) {
@@ -108,15 +229,220 @@ export class RenderCtx {
108
229
  case "image": return this.media(n);
109
230
  case "link": return this.link(n);
110
231
  case "autoref": {
111
- const href = n.doc ? `${n.doc.replace(/\.geml$/, ".html")}#${n.anchor}` : `#${n.anchor}`;
112
- const label = n.doc ? (n.anchor ?? n.doc) : (this.labels.get(n.anchor) ?? n.anchor);
232
+ const href = n.doc ? `${relJoin(relDir(this.currentDocRel), n.doc).replace(/\.geml$/, ".html")}#${n.anchor}` : this.fragmentHref(n.anchor);
233
+ // §5.2: an auto-reference takes its text from the target's caption or
234
+ // heading. Across documents that means reading the target — which the
235
+ // build can do, since an embed pulls whole sections through the same hook.
236
+ // Inside borrowed content a fragment-only reference means an id of the
237
+ // BORROWED document, so its label has to come from there too. Taking it
238
+ // from `this.labels` showed the host's caption on a link whose destination
239
+ // is the source document's block — a text/target mismatch the host controls.
240
+ const label = n.doc
241
+ ? (this.remoteLabel(n.doc, n.anchor) ?? n.anchor ?? n.doc)
242
+ : (this.currentLabels().get(n.anchor) ?? n.anchor);
113
243
  return `<a href="${escAttr(href)}">${esc(label)}</a>`;
114
244
  }
115
- case "footnote": return `<sup class="fn"><a href="#${escAttr(n.ref)}">${esc(n.ref)}</a></sup>`;
245
+ case "project": return this.projectInline(n);
246
+ // Through fragmentHref like every other fragment-only reference: borrowed
247
+ // content owns no anchors, so a bare `#ref` here landed on a same-named
248
+ // footnote of the HOST — letting the host author choose what a borrowed
249
+ // sentence's citation says.
250
+ case "footnote": return `<sup class="fn"><a href="${escAttr(this.fragmentHref(n.ref))}">${esc(n.ref)}</a></sup>`;
251
+ }
252
+ }
253
+ // S2/S3/S5: expand a transclusion in place, wrapped in a container carrying its
254
+ // provenance. Every path that cannot expand falls back to a link to the target
255
+ // with the reason visible — never silently blank, never a broken image.
256
+ transclude(b, idAttr) {
257
+ const written = typeof b.attrs["src"] === "string" ? b.attrs["src"].trim() : "";
258
+ if (written === "")
259
+ return this.transclusionFallback("", idAttr, "invalid", "embed: missing `src=`");
260
+ const hash = written.indexOf("#");
261
+ const docPath = hash < 0 ? written : written.slice(0, hash);
262
+ const anchor = hash < 0 ? undefined : written.slice(hash + 1);
263
+ const { loadDoc, parseDoc } = this.opts;
264
+ // A same-document target (`src=#id`) selects from the document CURRENTLY being
265
+ // expanded, which inside borrowed content is the borrowed document, not the
266
+ // host. And it takes a cycle key like any other: the slice a heading id
267
+ // selects contains the embed that selected it, which is the smallest cycle
268
+ // there is. Skipping the key here is what let a 7-line document expand into
269
+ // 256 copies of itself, stopped only by the generic block-nesting guard.
270
+ const rel = docPath === "" ? this.currentDocRel : relJoin(relDir(this.currentDocRel), docPath);
271
+ const key = anchor === undefined ? rel : `${rel}#${anchor}`;
272
+ if (this.embedStack.includes(key)) {
273
+ return `<div class="transclusion transclusion-error"${idAttr} data-src="${escAttr(written)}">transclusion cycle: ${esc([...this.embedStack, key].join(" → "))}</div>`;
274
+ }
275
+ if (this.embedStack.length >= EMBED_DEPTH_CAP) {
276
+ return this.transclusionFallback(written, idAttr, "too-deep", `transclusion depth cap (${EMBED_DEPTH_CAP}) reached`);
277
+ }
278
+ const spent = this.budgetExhausted();
279
+ if (spent !== null)
280
+ return this.transclusionFallback(written, idAttr, "too-large", spent);
281
+ let children;
282
+ if (docPath !== "" && !/\.geml$/i.test(docPath)) {
283
+ // Same constraint the parser reports: an embed stands for a GEML document.
284
+ // Parsing whatever else the target happens to contain injected its bytes
285
+ // into the page as prose.
286
+ return this.transclusionFallback(written, idAttr, "invalid", `\`${docPath}\` is not a GEML document`);
287
+ }
288
+ if (docPath === "") {
289
+ children = this.currentDocChildren;
290
+ }
291
+ else {
292
+ if (!loadDoc || !parseDoc)
293
+ return this.transclusionFallback(written, idAttr, "unexpanded", "no document resolver");
294
+ // Parsed on its own, so S4 holds for free: `{{key}}` inside borrowed content
295
+ // interpolates against the SOURCE document's meta, never the host's. Read
296
+ // through the cache: the same target is otherwise re-read and re-parsed once
297
+ // per expansion.
298
+ const loaded = this.loadChildren(rel);
299
+ if (loaded === null)
300
+ return this.transclusionFallback(written, idAttr, "unresolved", `cannot resolve document \`${docPath}\`, or it is too large`);
301
+ children = loaded;
302
+ }
303
+ const picked = selectEmbed(children, anchor);
304
+ if (picked === null) {
305
+ const what = docPath === "" ? `no \`${written}\` in this document` : `no \`#${anchor}\` in \`${docPath}\``;
306
+ return this.transclusionFallback(written, idAttr, "unresolved", what);
307
+ }
308
+ this.embedStack.push(key);
309
+ this.embedDocs.push({ rel, children });
310
+ try {
311
+ return this.transclusionWrap(written, idAttr, picked);
312
+ }
313
+ finally {
314
+ this.embedDocs.pop();
315
+ this.embedStack.pop();
316
+ }
317
+ }
318
+ // An inline projection: the target block's body, rendered here. Deliberately the
319
+ // same machinery as the block form — the cycle stack, the depth cap, and the
320
+ // document chain that drives S4 rebasing and the fragment-only rewrite — rather
321
+ // than a second path that would have to be kept in step with it. A projected
322
+ // phrase carrying a link is the normal case, so that rewrite matters more here.
323
+ projectInline(n) {
324
+ const written = n.doc === undefined ? `#${n.anchor}` : `${n.doc}#${n.anchor}`;
325
+ const { loadDoc, parseDoc } = this.opts;
326
+ const rel = n.doc === undefined ? this.currentDocRel : relJoin(relDir(this.currentDocRel), n.doc);
327
+ const key = `${rel}#${n.anchor}`;
328
+ if (this.embedStack.includes(key))
329
+ return this.projectFallback(written, "error", "transclusion cycle");
330
+ if (this.embedStack.length >= EMBED_DEPTH_CAP)
331
+ return this.projectFallback(written, "too-deep", `depth cap (${EMBED_DEPTH_CAP})`);
332
+ const spentHere = this.budgetExhausted();
333
+ if (spentHere !== null)
334
+ return this.projectFallback(written, "too-large", spentHere);
335
+ let children;
336
+ if (n.doc === undefined)
337
+ children = this.currentDocChildren;
338
+ else {
339
+ if (!loadDoc || !parseDoc)
340
+ return this.projectFallback(written, "unexpanded", "no document resolver");
341
+ const loaded = this.loadChildren(rel);
342
+ if (loaded === null)
343
+ return this.projectFallback(written, "unresolved", "unresolvable document, or too large");
344
+ children = loaded;
345
+ }
346
+ const got = projectableInlines(children, n.anchor);
347
+ if (got === null || got === "not-inline")
348
+ return this.projectFallback(written, "unresolved", "not inline content");
349
+ this.embedStack.push(key);
350
+ this.embedDocs.push({ rel, children });
351
+ try {
352
+ this.embedCount++;
353
+ const inner = this.inlines(got.inlines);
354
+ this.embedBytes += inner.length;
355
+ return `<span class="transclusion-inline" data-src="${escAttr(written)}">${inner}</span>`;
356
+ }
357
+ finally {
358
+ this.embedDocs.pop();
359
+ this.embedStack.pop();
116
360
  }
117
361
  }
362
+ projectFallback(written, why, note) {
363
+ const hash = written.indexOf("#");
364
+ const docPath = written.slice(0, hash);
365
+ const href = docPath === "" ? written : relJoin(relDir(this.currentDocRel), docPath).replace(/\.geml$/, ".html") + written.slice(hash);
366
+ const safe = isSafeUrl(href) ? href : "#";
367
+ return `<span class="transclusion-inline transclusion-${classAttrToken(why)}" data-src="${escAttr(written)}" title="${escAttr(note)}">`
368
+ + `<a href="${escAttr(safe)}">${esc(written)}</a></span>`;
369
+ }
370
+ // The document a transclusion is currently selecting from — the host until an
371
+ // expansion is in progress. `rel` is its path relative to the rendered host, so
372
+ // everything relative inside it composes through `relDir(rel)` (S4), and any
373
+ // fragment-only reference resolves against that document's own page.
374
+ get currentDocRel() {
375
+ return this.embedDocs.length === 0 ? "" : this.embedDocs[this.embedDocs.length - 1].rel;
376
+ }
377
+ get currentDocChildren() {
378
+ return this.embedDocs.length === 0 ? this.doc.children : this.embedDocs[this.embedDocs.length - 1].children;
379
+ }
380
+ // Labels of the document currently being expanded, built on first use per frame.
381
+ currentLabels() {
382
+ if (this.embedDocs.length === 0)
383
+ return this.labels;
384
+ const frame = this.embedDocs[this.embedDocs.length - 1];
385
+ if (frame.labels === undefined) {
386
+ frame.labels = new Map();
387
+ indexLabelsInto(frame.children, frame.labels);
388
+ }
389
+ return frame.labels;
390
+ }
391
+ // S9: borrowed content contributes no anchors to the host page. Two ids named
392
+ // the same is invalid HTML, and an in-page link to one of them would land on
393
+ // whichever the browser picked. The host keeps its own ids; a borrowed copy has
394
+ // none, and references into it resolve against its source document instead.
395
+ idAttr(id) {
396
+ return id === undefined || this.embedDocs.length > 0 ? "" : ` id="${escAttr(id)}"`;
397
+ }
398
+ // A fragment-only reference (`#id`, `[[#id]]`) inside borrowed content means an
399
+ // id of the BORROWED document. On the host page that anchor does not exist — or,
400
+ // worse, a same-named host block silently answers for it — so it points at the
401
+ // source document's page.
402
+ // The label a target document gives an id, for a cross-document auto-reference.
403
+ // Memoized per document: one page can reference the same document many times.
404
+ remoteLabels = new Map();
405
+ remoteLabel(doc, anchor) {
406
+ const { loadDoc, parseDoc } = this.opts;
407
+ if (!loadDoc || !parseDoc)
408
+ return undefined;
409
+ const rel = relJoin(relDir(this.currentDocRel), doc);
410
+ let labels = this.remoteLabels.get(rel);
411
+ if (labels === undefined) {
412
+ labels = new Map();
413
+ const src = loadDoc(rel);
414
+ if (src !== null)
415
+ indexLabelsInto(parseDoc(src).children, labels);
416
+ this.remoteLabels.set(rel, labels);
417
+ }
418
+ return labels.get(anchor);
419
+ }
420
+ fragmentHref(anchor) {
421
+ const rel = this.currentDocRel;
422
+ return rel === "" ? `#${anchor}` : `${rel.replace(/\.geml$/, ".html")}#${anchor}`;
423
+ }
424
+ transclusionWrap(written, idAttr, picked) {
425
+ this.embedCount++;
426
+ const inner = picked.map((x) => this.block(x)).filter((s) => s !== "").join("\n");
427
+ this.embedBytes += inner.length;
428
+ return `<section class="transclusion"${idAttr} data-src="${escAttr(written)}">${inner}</section>`;
429
+ }
430
+ transclusionFallback(written, idAttr, why, note) {
431
+ const hash = written.indexOf("#");
432
+ const docPath = hash < 0 ? written : written.slice(0, hash);
433
+ const frag = hash < 0 ? "" : written.slice(hash);
434
+ const href = docPath === "" ? frag : relJoin(relDir(this.currentDocRel), docPath).replace(/\.geml$/, ".html") + frag;
435
+ // Defence in depth: the parse layer already blanks an unsafe scheme (§9.5), so
436
+ // this should be unreachable. It is here because a fallback that composes an
437
+ // href from document text is exactly where a missed filter upstream becomes a
438
+ // live `javascript:` link — the shape of the one Critical finding in review.
439
+ const safe = isSafeUrl(href) ? href : "#";
440
+ const link = written === "" ? "" : `<a href="${escAttr(safe)}">${esc(written)}</a> `;
441
+ return `<div class="transclusion transclusion-${classAttrToken(why)}"${idAttr} data-src="${escAttr(written)}" title="${escAttr(note)}">`
442
+ + `${link}<span class="transclusion-note">${esc(note)}</span></div>`;
443
+ }
118
444
  media(n) {
119
- const src = escAttr(n.src);
445
+ const src = escAttr(relJoin(relDir(this.currentDocRel), n.src));
120
446
  if (n.as === "video")
121
447
  return `<video class="media" src="${src}" controls></video>`;
122
448
  if (n.as === "audio")
@@ -128,9 +454,9 @@ export class RenderCtx {
128
454
  if (n.href)
129
455
  href = n.href;
130
456
  else if (n.doc)
131
- href = `${n.doc.replace(/\.geml$/, ".html")}${n.anchor ? "#" + n.anchor : ""}`;
457
+ href = `${relJoin(relDir(this.currentDocRel), n.doc).replace(/\.geml$/, ".html")}${n.anchor ? "#" + n.anchor : ""}`;
132
458
  else if (n.anchor)
133
- href = `#${n.anchor}`;
459
+ href = this.fragmentHref(n.anchor);
134
460
  const rel = typeof n.attrs["rel"] === "string" ? ` rel="${escAttr(n.attrs["rel"])}"` : "";
135
461
  const target = typeof n.attrs["target"] === "string" ? ` target="${escAttr(n.attrs["target"])}"` : "";
136
462
  return `<a href="${escAttr(href)}"${rel}${target}>${this.inlines(n.children)}</a>`;
@@ -156,7 +482,7 @@ export class RenderCtx {
156
482
  case "heading": {
157
483
  if (b.hidden)
158
484
  return "";
159
- const id = b.id ? ` id="${escAttr(b.id)}"` : "";
485
+ const id = this.idAttr(b.id);
160
486
  const lvl = Math.min(6, Math.max(1, b.level));
161
487
  return `<h${lvl}${id}>${this.inlines(b.inlines)}</h${lvl}>`;
162
488
  }
@@ -188,7 +514,7 @@ export class RenderCtx {
188
514
  return ""; // {hidden}: in the model, never rendered
189
515
  const raw = (b.raw ?? []).join("\n");
190
516
  const caption = typeof b.attrs["caption"] === "string" ? b.attrs["caption"] : undefined;
191
- const idAttr = b.id ? ` id="${escAttr(b.id)}"` : "";
517
+ const idAttr = this.idAttr(b.id);
192
518
  switch (b.type) {
193
519
  case "meta": return ""; // header metadata, not body content
194
520
  case "code": {
@@ -196,8 +522,7 @@ export class RenderCtx {
196
522
  const cls = lang ? ` class="language-${escAttr(lang)}"` : "";
197
523
  return `<pre${idAttr}><code${cls}>${esc(raw)}</code></pre>`;
198
524
  }
199
- case "output":
200
- return `<pre class="output"${idAttr}><code>${esc(raw)}</code></pre>`;
525
+ case "embed": return this.transclude(b, idAttr);
201
526
  case "math":
202
527
  this.usedMath = true;
203
528
  return `<div class="math-block"${idAttr}>\\[${esc(raw)}\\]</div>`;
@@ -225,7 +550,7 @@ export class RenderCtx {
225
550
  }
226
551
  }
227
552
  diagram(b, raw, caption) {
228
- const idAttr = b.id ? ` id="${escAttr(b.id)}"` : "";
553
+ const idAttr = this.idAttr(b.id);
229
554
  const fmt = typeof b.attrs["format"] === "string" ? b.attrs["format"] : "";
230
555
  const cap = caption ? `<figcaption>${esc(caption)}</figcaption>` : "";
231
556
  if (fmt === "geml-chart") {
@@ -286,35 +611,14 @@ export class RenderCtx {
286
611
  const maxRows = this.isCodemapDoc && id === "modules" ? Infinity : (this.opts.tableRows ?? 500);
287
612
  const allRows = t.rows;
288
613
  const rows = allRows.length > maxRows ? allRows.slice(0, maxRows) : allRows;
289
- // Coverage grid for declared spans, so cells a span covers are not emitted.
290
- const covered = rows.map((r) => r.map(() => false));
291
- rows.forEach((row, r) => row.forEach((cell, c) => {
292
- if (!cell.span)
293
- return;
294
- // Bound the sweep to the rendered grid regardless of the declared span, so
295
- // an oversized span can never drive an O(hugerows×hugecols) loop (DoS).
296
- const spanRows = Math.min(cell.span.rows, rows.length - r);
297
- const spanCols = Math.min(cell.span.cols, row.length - c);
298
- for (let dr = 0; dr < spanRows; dr++)
299
- for (let dc = 0; dc < spanCols; dc++) {
300
- if (dr === 0 && dc === 0)
301
- continue;
302
- const rr = r + dr, cc = c + dc;
303
- if (covered[rr]?.[cc] !== undefined)
304
- covered[rr][cc] = true;
305
- }
306
- }));
307
614
  const thead = t.header
308
615
  ? `<thead><tr>${t.columns.map((col, c) => `<th${alignStyle(t.align[c])}>${esc(col)}</th>`).join("")}</tr></thead>`
309
616
  : "";
310
617
  const bodyRows = rows.map((row, r) => {
311
618
  const cells = row.map((cell, c) => {
312
- if (covered[r]?.[c])
313
- return "";
314
- const span = cell.span ? `${cell.span.rows > 1 ? ` rowspan="${cell.span.rows}"` : ""}${cell.span.cols > 1 ? ` colspan="${cell.span.cols}"` : ""}` : "";
315
619
  const cls = cell.computed ? ' class="computed"' : "";
316
620
  const sortVal = typeof cell.value === "number" ? ` data-sort="${cell.value}"` : "";
317
- return `<td${alignStyle(cell.align ?? t.align[c])}${span}${cls}${sortVal}>${this.inlines(cell.inlines)}</td>`;
621
+ return `<td${alignStyle(cell.align ?? t.align[c])}${cls}${sortVal}>${this.inlines(cell.inlines)}</td>`;
318
622
  }).join("");
319
623
  return `<tr>${cells}</tr>`;
320
624
  }).join("\n");
@@ -925,143 +1229,143 @@ function trunc(s, n) {
925
1229
  // ---------------------------------------------------------------------------
926
1230
  // Page shell, inline CSS, inline interactivity JS
927
1231
  // ---------------------------------------------------------------------------
928
- export const CSS = `
929
- :root { --fg:#1f2328; --muted:#656d76; --bd:#d0d7de; --bg:#fff; --accent:#2563eb; --code-bg:#f6f8fa; }
930
- * { box-sizing: border-box; }
931
- body { margin:0; color:var(--fg); background:#fafbfc; font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,"PingFang SC","Microsoft Yahei",sans-serif; }
932
- main { max-width: 860px; margin: 0 auto; padding: 48px 24px 96px; background:var(--bg); }
933
- h1,h2,h3,h4,h5,h6 { line-height:1.25; margin:1.6em 0 .6em; scroll-margin-top:16px; }
934
- h1 { font-size:2em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }
935
- h2 { font-size:1.5em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }
936
- h3 { font-size:1.25em; } h4 { font-size:1em; }
937
- p { margin:.7em 0; }
938
- a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
939
- code { background:var(--code-bg); padding:.15em .35em; border-radius:6px; font:.88em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
940
- pre { background:var(--code-bg); padding:14px 16px; border-radius:8px; overflow:auto; }
941
- pre code { background:none; padding:0; font-size:.85em; }
942
- pre.output { background:#0d1117; color:#e6edf3; }
943
- pre.output code { color:inherit; }
944
- ul,ol { padding-left:1.6em; } li { margin:.2em 0; }
945
- ul.task-list { list-style:none; padding-left:.2em; }
946
- li.task input[type=checkbox] { appearance:none; -webkit-appearance:none; width:1.1em; height:1.1em; margin:0 .5em 0 0; vertical-align:-.2em; border:1.5px solid #c8ccd0; border-radius:4px; background:#fff; position:relative; opacity:1; cursor:default; box-sizing:border-box; }
947
- li.task input[type=checkbox]:checked { background-color:#1f883d; border-color:#1f883d; }
948
- li.task input[type=checkbox]:checked::after { content:"✓"; position:absolute; top:0; right:0; bottom:0; left:0; display:flex; align-items:center; justify-content:center; color:#fff; font-size:.8em; line-height:1; font-weight:700; }
949
- aside.callout { border-left:4px solid var(--accent); background:#f0f6ff; padding:.4em 16px; border-radius:0 8px 8px 0; margin:1em 0; }
950
- aside.aside { border-left-color:#8b949e; background:#f6f8fa; }
951
- aside.warning { border-left-color:#d97706; background:#fff8f0; }
952
- aside.callout > :first-child { margin-top:0; } aside.callout > :last-child { margin-bottom:0; }
953
- figure { margin:1.2em 0; }
954
- figcaption { color:var(--muted); font-size:.86em; text-align:center; margin-top:.5em; }
955
- table.geml-table { border-collapse:collapse; width:100%; font-size:.92em; }
956
- table.geml-table th, table.geml-table td { border:1px solid var(--bd); padding:6px 12px; }
957
- table.geml-table thead th { background:var(--code-bg); cursor:pointer; user-select:none; white-space:nowrap; }
958
- table.geml-table thead th::after { content:" \\2195"; color:var(--muted); font-size:.8em; }
959
- table.geml-table thead th.asc::after { content:" \\2191"; color:var(--accent); }
960
- table.geml-table thead th.desc::after { content:" \\2193"; color:var(--accent); }
961
- table.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }
962
- table.geml-table td.computed { color:#0a7c52; }
963
- table.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }
964
- .table-tools { margin-bottom:6px; } .table-filter { width:240px; max-width:100%; padding:5px 9px; border:1px solid var(--bd); border-radius:7px; font-size:.85em; }
965
- .table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }
966
- .table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }
967
- .geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }
968
- .c-title { font-size:15px; font-weight:600; fill:var(--fg); }
969
- .c-grid { stroke:#eaecef; } .c-axis { stroke:#aab1b8; } .c-tick { font-size:11px; fill:var(--muted); } .c-legend { font-size:12px; fill:var(--fg); }
970
- .media { max-width:100%; border-radius:8px; }
971
- .diagram-src { color:var(--muted); } .render-error { color:#cf222e; }
972
- .math-block { overflow-x:auto; padding:.4em 0; }
973
- sup.fn a { font-size:.75em; }
974
- .geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }
975
- .geml-footer code { font-size:.95em; }
976
- .code-graph { margin:1.4em 0; }
977
- .cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }
978
- .cg-scroll { overflow:auto; min-height:52vh; max-height:72vh; }
979
- .cg-svg { display:block; }
980
- .cg-search-wrap { position:relative; display:inline-block; }
981
- .cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }
982
- .cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }
983
- .cg-search-row { display:block; width:100%; text-align:left; padding:4px 9px 4px 18px; border:0; background:none; color:var(--fg); cursor:pointer; font:12px/1.4 inherit; }
984
- .cg-search-row:hover { background:var(--bd); }
985
- .cg-search-count { position:sticky; top:0; padding:4px 9px; font-size:11px; opacity:.65; background:var(--bg); border-bottom:1px solid var(--bd); }
986
- .cg-search-grp { padding:6px 9px 2px; font-size:11px; font-weight:600; opacity:.7; border-top:1px solid var(--bd); }
987
- .cg-search-grp:first-of-type { border-top:0; }
988
- .cg-stage { display:flex; gap:10px; align-items:flex-start; }
989
- .cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }
990
- .cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }
991
- .cg-src-hd { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:4px 8px; border-bottom:1px solid var(--bd); color:var(--muted); font:.76em ui-monospace,Consolas,monospace; word-break:break-all; }
992
- .cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }
993
- .cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:72vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }
994
- .cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }
995
- .cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }
996
- .cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }
997
- .cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }
998
- .cg-crumb .cg-seg:hover { text-decoration:underline; }
999
- .cg-frame { display:block; width:100%; height:72vh; border:0; background:var(--bg); }
1000
- .cg-flash { color:#b42318; }
1001
- .cg-legend { display:flex; gap:14px; align-items:center; justify-content:space-between; flex-wrap:wrap; font-size:.75em; color:var(--muted); margin-top:6px; }
1002
- .cg-upbtn { cursor:pointer; }
1003
- .cg-upbtn circle { fill:#fff; stroke:#94a3b8; }
1004
- .cg-upbtn text { font-size:11px; fill:#57606a; }
1005
- .cg-upbtn:hover circle { stroke:var(--accent); stroke-width:1.6; }
1006
- .cg-upbtn:hover text { fill:var(--accent); }
1007
- .cg-uplink { fill:none; stroke:#94a3b8; stroke-dasharray:3 2.5; pointer-events:none; }
1008
- .cg-groups { display:flex; flex-wrap:wrap; gap:4px 12px; margin-top:6px; font-size:.75em; color:var(--muted); }
1009
- .cg-chip { display:inline-flex; align-items:center; gap:4px; }
1010
- .cg-chip i { width:10px; height:10px; border-radius:2px; border:1px solid #94a3b8; display:inline-block; }
1011
- .cg-note { font-size:.8em; color:#9a6700; }
1012
- .cg-n rect { fill:#eef2f7; stroke:#94a3b8; }
1013
- .cg-n text { font-size:12px; fill:var(--fg); font-family:ui-monospace,Consolas,monospace; }
1014
- .cg-n { cursor:pointer; }
1015
- .cg-n.root rect { fill:#dbeafe; stroke:#2563eb; stroke-width:2; }
1016
- .cg-n.leaf { opacity:.45; }
1017
- .cg-n.test rect { stroke-dasharray:3 2; }
1018
- .cg-n.grp rect { stroke-width:1.8; }
1019
- .cg-e { fill:none; stroke:#94a3b8; stroke-width:.9; }
1020
- .cg-e.cand { stroke-dasharray:2 3; }
1021
- .cg-e.back { stroke:#dc2626; stroke-dasharray:5 3; }
1022
- .cg-e.http { stroke:#0891b2; stroke-width:1.5; stroke-dasharray:5 2; } /* cross-stack API link */
1023
- .cg-e.soft { opacity:.55; }
1024
- .cg-svg.hl .cg-n { opacity:.22; }
1025
- .cg-svg.hl .cg-e { opacity:.1; }
1026
- .cg-svg.hl .cg-n.hl { opacity:1; }
1027
- .cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }
1232
+ export const CSS = `
1233
+ :root { --fg:#1f2328; --muted:#656d76; --bd:#d0d7de; --bg:#fff; --accent:#2563eb; --code-bg:#f6f8fa; }
1234
+ * { box-sizing: border-box; }
1235
+ body { margin:0; color:var(--fg); background:#fafbfc; font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,"PingFang SC","Microsoft Yahei",sans-serif; }
1236
+ main { max-width: 860px; margin: 0 auto; padding: 48px 24px 96px; background:var(--bg); }
1237
+ h1,h2,h3,h4,h5,h6 { line-height:1.25; margin:1.6em 0 .6em; scroll-margin-top:16px; }
1238
+ h1 { font-size:2em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }
1239
+ h2 { font-size:1.5em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }
1240
+ h3 { font-size:1.25em; } h4 { font-size:1em; }
1241
+ p { margin:.7em 0; }
1242
+ a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
1243
+ code { background:var(--code-bg); padding:.15em .35em; border-radius:6px; font:.88em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
1244
+ pre { background:var(--code-bg); padding:14px 16px; border-radius:8px; overflow:auto; }
1245
+ pre code { background:none; padding:0; font-size:.85em; }
1246
+ pre.output { background:#0d1117; color:#e6edf3; }
1247
+ pre.output code { color:inherit; }
1248
+ ul,ol { padding-left:1.6em; } li { margin:.2em 0; }
1249
+ ul.task-list { list-style:none; padding-left:.2em; }
1250
+ li.task input[type=checkbox] { appearance:none; -webkit-appearance:none; width:1.1em; height:1.1em; margin:0 .5em 0 0; vertical-align:-.2em; border:1.5px solid #c8ccd0; border-radius:4px; background:#fff; position:relative; opacity:1; cursor:default; box-sizing:border-box; }
1251
+ li.task input[type=checkbox]:checked { background-color:#1f883d; border-color:#1f883d; }
1252
+ li.task input[type=checkbox]:checked::after { content:"✓"; position:absolute; top:0; right:0; bottom:0; left:0; display:flex; align-items:center; justify-content:center; color:#fff; font-size:.8em; line-height:1; font-weight:700; }
1253
+ aside.callout { border-left:4px solid var(--accent); background:#f0f6ff; padding:.4em 16px; border-radius:0 8px 8px 0; margin:1em 0; }
1254
+ aside.aside { border-left-color:#8b949e; background:#f6f8fa; }
1255
+ aside.warning { border-left-color:#d97706; background:#fff8f0; }
1256
+ aside.callout > :first-child { margin-top:0; } aside.callout > :last-child { margin-bottom:0; }
1257
+ figure { margin:1.2em 0; }
1258
+ figcaption { color:var(--muted); font-size:.86em; text-align:center; margin-top:.5em; }
1259
+ table.geml-table { border-collapse:collapse; width:100%; font-size:.92em; }
1260
+ table.geml-table th, table.geml-table td { border:1px solid var(--bd); padding:6px 12px; }
1261
+ table.geml-table thead th { background:var(--code-bg); cursor:pointer; user-select:none; white-space:nowrap; }
1262
+ table.geml-table thead th::after { content:" \\2195"; color:var(--muted); font-size:.8em; }
1263
+ table.geml-table thead th.asc::after { content:" \\2191"; color:var(--accent); }
1264
+ table.geml-table thead th.desc::after { content:" \\2193"; color:var(--accent); }
1265
+ table.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }
1266
+ table.geml-table td.computed { color:#0a7c52; }
1267
+ table.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }
1268
+ .table-tools { margin-bottom:6px; } .table-filter { width:240px; max-width:100%; padding:5px 9px; border:1px solid var(--bd); border-radius:7px; font-size:.85em; }
1269
+ .table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }
1270
+ .table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }
1271
+ .geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }
1272
+ .c-title { font-size:15px; font-weight:600; fill:var(--fg); }
1273
+ .c-grid { stroke:#eaecef; } .c-axis { stroke:#aab1b8; } .c-tick { font-size:11px; fill:var(--muted); } .c-legend { font-size:12px; fill:var(--fg); }
1274
+ .media { max-width:100%; border-radius:8px; }
1275
+ .diagram-src { color:var(--muted); } .render-error { color:#cf222e; }
1276
+ .math-block { overflow-x:auto; padding:.4em 0; }
1277
+ sup.fn a { font-size:.75em; }
1278
+ .geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }
1279
+ .geml-footer code { font-size:.95em; }
1280
+ .code-graph { margin:1.4em 0; }
1281
+ .cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }
1282
+ .cg-scroll { overflow:auto; min-height:52vh; max-height:72vh; }
1283
+ .cg-svg { display:block; }
1284
+ .cg-search-wrap { position:relative; display:inline-block; }
1285
+ .cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }
1286
+ .cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }
1287
+ .cg-search-row { display:block; width:100%; text-align:left; padding:4px 9px 4px 18px; border:0; background:none; color:var(--fg); cursor:pointer; font:12px/1.4 inherit; }
1288
+ .cg-search-row:hover { background:var(--bd); }
1289
+ .cg-search-count { position:sticky; top:0; padding:4px 9px; font-size:11px; opacity:.65; background:var(--bg); border-bottom:1px solid var(--bd); }
1290
+ .cg-search-grp { padding:6px 9px 2px; font-size:11px; font-weight:600; opacity:.7; border-top:1px solid var(--bd); }
1291
+ .cg-search-grp:first-of-type { border-top:0; }
1292
+ .cg-stage { display:flex; gap:10px; align-items:flex-start; }
1293
+ .cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }
1294
+ .cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }
1295
+ .cg-src-hd { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:4px 8px; border-bottom:1px solid var(--bd); color:var(--muted); font:.76em ui-monospace,Consolas,monospace; word-break:break-all; }
1296
+ .cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }
1297
+ .cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:72vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }
1298
+ .cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }
1299
+ .cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }
1300
+ .cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }
1301
+ .cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }
1302
+ .cg-crumb .cg-seg:hover { text-decoration:underline; }
1303
+ .cg-frame { display:block; width:100%; height:72vh; border:0; background:var(--bg); }
1304
+ .cg-flash { color:#b42318; }
1305
+ .cg-legend { display:flex; gap:14px; align-items:center; justify-content:space-between; flex-wrap:wrap; font-size:.75em; color:var(--muted); margin-top:6px; }
1306
+ .cg-upbtn { cursor:pointer; }
1307
+ .cg-upbtn circle { fill:#fff; stroke:#94a3b8; }
1308
+ .cg-upbtn text { font-size:11px; fill:#57606a; }
1309
+ .cg-upbtn:hover circle { stroke:var(--accent); stroke-width:1.6; }
1310
+ .cg-upbtn:hover text { fill:var(--accent); }
1311
+ .cg-uplink { fill:none; stroke:#94a3b8; stroke-dasharray:3 2.5; pointer-events:none; }
1312
+ .cg-groups { display:flex; flex-wrap:wrap; gap:4px 12px; margin-top:6px; font-size:.75em; color:var(--muted); }
1313
+ .cg-chip { display:inline-flex; align-items:center; gap:4px; }
1314
+ .cg-chip i { width:10px; height:10px; border-radius:2px; border:1px solid #94a3b8; display:inline-block; }
1315
+ .cg-note { font-size:.8em; color:#9a6700; }
1316
+ .cg-n rect { fill:#eef2f7; stroke:#94a3b8; }
1317
+ .cg-n text { font-size:12px; fill:var(--fg); font-family:ui-monospace,Consolas,monospace; }
1318
+ .cg-n { cursor:pointer; }
1319
+ .cg-n.root rect { fill:#dbeafe; stroke:#2563eb; stroke-width:2; }
1320
+ .cg-n.leaf { opacity:.45; }
1321
+ .cg-n.test rect { stroke-dasharray:3 2; }
1322
+ .cg-n.grp rect { stroke-width:1.8; }
1323
+ .cg-e { fill:none; stroke:#94a3b8; stroke-width:.9; }
1324
+ .cg-e.cand { stroke-dasharray:2 3; }
1325
+ .cg-e.back { stroke:#dc2626; stroke-dasharray:5 3; }
1326
+ .cg-e.http { stroke:#0891b2; stroke-width:1.5; stroke-dasharray:5 2; } /* cross-stack API link */
1327
+ .cg-e.soft { opacity:.55; }
1328
+ .cg-svg.hl .cg-n { opacity:.22; }
1329
+ .cg-svg.hl .cg-e { opacity:.1; }
1330
+ .cg-svg.hl .cg-n.hl { opacity:1; }
1331
+ .cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }
1028
1332
  `;
1029
- export const JS = `
1030
- (function () {
1031
- function cmp(a, b) {
1032
- var na = a.dataset.sort, nb = b.dataset.sort;
1033
- if (na !== undefined && nb !== undefined) return parseFloat(na) - parseFloat(nb);
1034
- return (a.textContent || "").localeCompare(b.textContent || "");
1035
- }
1036
- document.querySelectorAll("table.geml-table").forEach(function (table) {
1037
- var tbody = table.tBodies[0];
1038
- if (!tbody) return;
1039
- // Sort on header click.
1040
- var ths = table.tHead ? table.tHead.rows[0].cells : [];
1041
- Array.prototype.forEach.call(ths, function (th, col) {
1042
- th.addEventListener("click", function () {
1043
- var dir = th.classList.contains("asc") ? "desc" : "asc";
1044
- Array.prototype.forEach.call(ths, function (h) { h.classList.remove("asc", "desc"); });
1045
- th.classList.add(dir);
1046
- var rows = Array.prototype.slice.call(tbody.rows);
1047
- rows.sort(function (r1, r2) {
1048
- var c = cmp(r1.cells[col], r2.cells[col]);
1049
- return dir === "asc" ? c : -c;
1050
- });
1051
- rows.forEach(function (r) { tbody.appendChild(r); });
1052
- });
1053
- });
1054
- // Filter rows.
1055
- var fig = table.closest(".table-figure");
1056
- var input = fig ? fig.querySelector(".table-filter") : null;
1057
- if (input) input.addEventListener("input", function () {
1058
- var q = input.value.toLowerCase();
1059
- Array.prototype.forEach.call(tbody.rows, function (r) {
1060
- r.style.display = (r.textContent || "").toLowerCase().indexOf(q) >= 0 ? "" : "none";
1061
- });
1062
- });
1063
- });
1064
- })();
1333
+ export const JS = `
1334
+ (function () {
1335
+ function cmp(a, b) {
1336
+ var na = a.dataset.sort, nb = b.dataset.sort;
1337
+ if (na !== undefined && nb !== undefined) return parseFloat(na) - parseFloat(nb);
1338
+ return (a.textContent || "").localeCompare(b.textContent || "");
1339
+ }
1340
+ document.querySelectorAll("table.geml-table").forEach(function (table) {
1341
+ var tbody = table.tBodies[0];
1342
+ if (!tbody) return;
1343
+ // Sort on header click.
1344
+ var ths = table.tHead ? table.tHead.rows[0].cells : [];
1345
+ Array.prototype.forEach.call(ths, function (th, col) {
1346
+ th.addEventListener("click", function () {
1347
+ var dir = th.classList.contains("asc") ? "desc" : "asc";
1348
+ Array.prototype.forEach.call(ths, function (h) { h.classList.remove("asc", "desc"); });
1349
+ th.classList.add(dir);
1350
+ var rows = Array.prototype.slice.call(tbody.rows);
1351
+ rows.sort(function (r1, r2) {
1352
+ var c = cmp(r1.cells[col], r2.cells[col]);
1353
+ return dir === "asc" ? c : -c;
1354
+ });
1355
+ rows.forEach(function (r) { tbody.appendChild(r); });
1356
+ });
1357
+ });
1358
+ // Filter rows.
1359
+ var fig = table.closest(".table-figure");
1360
+ var input = fig ? fig.querySelector(".table-filter") : null;
1361
+ if (input) input.addEventListener("input", function () {
1362
+ var q = input.value.toLowerCase();
1363
+ Array.prototype.forEach.call(tbody.rows, function (r) {
1364
+ r.style.display = (r.textContent || "").toLowerCase().indexOf(q) >= 0 ? "" : "none";
1365
+ });
1366
+ });
1367
+ });
1368
+ })();
1065
1369
  `;
1066
1370
  // geml-code-graph runtime: layered layout AT DRAW TIME (GEP-0003 / v2-D8) so
1067
1371
  // clicking a node re-roots the view inside the embedded slice. Algorithm as