@geml/geml 1.0.0 → 1.3.2

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/history.d.ts CHANGED
@@ -15,6 +15,7 @@ interface Revision {
15
15
  author?: string;
16
16
  summary?: string;
17
17
  hash: string;
18
+ newline?: string;
18
19
  ops: Op[];
19
20
  }
20
21
  interface History {
@@ -52,4 +53,33 @@ export interface RestoreOpts {
52
53
  force?: boolean;
53
54
  }
54
55
  export declare function restore(o: RestoreOpts): string;
56
+ export interface RevisionInfo {
57
+ id: string;
58
+ parent?: string;
59
+ author?: string;
60
+ summary?: string;
61
+ hash: string;
62
+ offset: number;
63
+ current: boolean;
64
+ }
65
+ /** Is the working file byte-identical to the sidecar's tip revision? False
66
+ * means uncommitted drift (e.g. an earlier commit attempt was refused). */
67
+ export declare function isCurrent(historyPath: string, gemlPath: string): boolean;
68
+ /** Revisions newest-first, each tagged with the `-N` offset that selects it. */
69
+ export declare function listRevisions(historyPath: string): RevisionInfo[];
70
+ /** Resolve a revision selector to its id + reconstructed full text. Selectors:
71
+ * `-N` (N revisions back from current; `-0` is the tip), `latest`/`current`, or
72
+ * an unambiguous id prefix/suffix (the same forms `restore` accepts). */
73
+ export declare function resolveContent(historyPath: string, selector: string): {
74
+ id: string;
75
+ text: string;
76
+ };
77
+ /** Walk the chain newest→oldest; return the first revision whose block (as
78
+ * extracted by `pick`) differs from `currentBlock` — i.e. the block's previous
79
+ * *distinct* version, skipping revisions that never touched it. Used by
80
+ * `revert --changed`. `undefined` if no earlier revision changed the block. */
81
+ export declare function firstChangedContent(historyPath: string, currentBlock: string, pick: (fullText: string) => string | undefined): {
82
+ id: string;
83
+ text: string;
84
+ } | undefined;
55
85
  export {};
package/dist/history.js CHANGED
@@ -80,9 +80,12 @@ function fenceFor(contentLf) {
80
80
  // ---------------------------------------------------------------------------
81
81
  // Document units & reverse-patch engine (gap-aware)
82
82
  // ---------------------------------------------------------------------------
83
- // Unit-key = `#id` (explicit), or `@<8hex content hash>` (derived) with `~n`
84
- // disambiguating equal-content units by document-order occurrence (§4).
85
- const KEY = String.raw `(#[A-Za-z][A-Za-z0-9_-]*|@[0-9a-f]+(?:~\d+)?)`;
83
+ // Unit-key = `#id` (explicit), or `@<8hex content hash>` (derived), with `~n`
84
+ // disambiguating equal keys by document-order occurrence (§4). `~n` on an #id
85
+ // key only arises for OUT-OF-SPEC documents that repeat an id — without it the
86
+ // key is ambiguous, reverse-patch ops hit the wrong occurrence, and commit()'s
87
+ // round-trip gate (correctly) aborts. Well-formed documents never emit it.
88
+ const KEY = String.raw `(#[A-Za-z][A-Za-z0-9_-]*(?:~\d+)?|@[0-9a-f]+(?:~\d+)?)`;
86
89
  function sha8(s) {
87
90
  return createHash("sha256").update(Buffer.from(s, "utf8")).digest("hex").slice(0, 8);
88
91
  }
@@ -127,9 +130,11 @@ function tile(lines) {
127
130
  function keyedUnits(lines) {
128
131
  const counts = new Map();
129
132
  return tile(lines).map((u) => {
130
- if (u.id)
131
- return { u, key: `#${u.id}` };
132
- const base = `@${sha8(lines.slice(u.start, u.bodyEnd).join("\n"))}`;
133
+ // Both key kinds get the ~n occurrence suffix: content keys collide by
134
+ // nature (equal blank runs, repeated paragraphs); #id keys only collide in
135
+ // out-of-spec documents that repeat an id — but those exist in the wild,
136
+ // and an ambiguous key sends reverse-patch ops to the wrong occurrence.
137
+ const base = u.id ? `#${u.id}` : `@${sha8(lines.slice(u.start, u.bodyEnd).join("\n"))}`;
133
138
  const n = counts.get(base) ?? 0;
134
139
  counts.set(base, n + 1);
135
140
  return { u, key: n === 0 ? base : `${base}~${n}` };
@@ -200,16 +205,46 @@ function applyReverse(textLf, ops, blobs) {
200
205
  throw new Error(`history: unresolved blob:${id}`);
201
206
  return p.split("\n");
202
207
  };
208
+ // `delete`/`replace` name units in the INPUT document (v_new) — their `~n`
209
+ // occurrence suffixes are numbered over the whole of v_new. So resolve them
210
+ // against ONE snapshot of the unmutated input, not the live array: re-keying
211
+ // per op lets an earlier delete renumber a later op's occurrence (delete @h
212
+ // then delete @h~1 — after the first splice the survivor renumbers @h~1→@h
213
+ // and the second op can no longer find it). Keys are unique within a single
214
+ // keyedUnits() call, so the snapshot map is a bijection.
215
+ const snap = keyedUnits(lines);
216
+ const byKey = new Map(snap.map((k) => [k.key, k.u]));
217
+ const resolveSnap = (key) => {
218
+ const u = byKey.get(key);
219
+ if (!u)
220
+ throw new Error(`history: unit ${key} not found while applying reverse patch`);
221
+ return u;
222
+ };
223
+ const rangeEdits = [];
224
+ const anchored = []; // insert / move — resolved against the LIVE array below
203
225
  for (const op of ops) {
204
226
  if (op.kind === "delete") {
205
- const u = locateUnit(lines, op.key);
206
- lines.splice(u.start, u.endExcl - u.start);
227
+ const u = resolveSnap(op.key);
228
+ rangeEdits.push({ start: u.start, endExcl: u.endExcl, repl: [] });
207
229
  }
208
230
  else if (op.kind === "replace") {
209
- const u = locateUnit(lines, op.key);
210
- lines.splice(u.start, u.endExcl - u.start, ...blob(op.blob));
231
+ const u = resolveSnap(op.key);
232
+ rangeEdits.push({ start: u.start, endExcl: u.endExcl, repl: blob(op.blob) });
211
233
  }
212
- else if (op.kind === "insert") {
234
+ else {
235
+ anchored.push(op);
236
+ }
237
+ }
238
+ rangeEdits.sort((a, b) => b.start - a.start);
239
+ for (const e of rangeEdits)
240
+ lines.splice(e.start, e.endExcl - e.start, ...e.repl);
241
+ // Inserts (and moves) run only after every delete/replace, so the array is
242
+ // already in its reconstructed (v_parent) shape: an insert's anchor names a
243
+ // v_parent unit, and chained inserts build on units added just before them —
244
+ // both need LIVE re-keying, which is correct here precisely because the
245
+ // occurrence keying is now stable (no more same-keyspace ops pending).
246
+ for (const op of anchored) {
247
+ if (op.kind === "insert") {
213
248
  insertAt(lines, op.anchor, blob(op.blob));
214
249
  }
215
250
  else { // move: cut the unit (with its owned blanks) and re-insert at anchor
@@ -221,25 +256,51 @@ function applyReverse(textLf, ops, blobs) {
221
256
  }
222
257
  return lines.join("\n");
223
258
  }
224
- /** LCS alignment of unit-key sequences; aMatch[i] = matched index in b, or -1. */
259
+ /** LCS alignment of unit-key sequences; aMatch[i] = matched index in b, or -1.
260
+ *
261
+ * keyedUnits() assigns keys that are unique WITHIN each sequence (the `~n`
262
+ * occurrence suffix disambiguates equal content / repeated ids), so both `a`
263
+ * and `b` are all-unique. The LCS of two all-unique sequences is exactly the
264
+ * longest increasing subsequence of a's keys mapped to b's positions:
265
+ * O(n log n) instead of an O(n·m) DP table — GEP-0002's measured bottleneck
266
+ * (seconds at 10⁴ units, minutes at 10⁵; code-graph documents live there).
267
+ *
268
+ * If keys were ever non-unique (no public entry point produces that — the only
269
+ * caller, diffReverse, feeds keyedUnits output), posInB keeps b's LAST index
270
+ * per key and the LIS still yields *a* valid monotonic matching; commit()'s
271
+ * byte-exact round-trip gate rejects any diff that fails to reproduce the
272
+ * parent regardless. */
225
273
  function lcsMatch(a, b) {
226
274
  const n = a.length, m = b.length;
227
- const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
228
- for (let i = n - 1; i >= 0; i--)
229
- for (let j = m - 1; j >= 0; j--)
230
- dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
231
275
  const aMatch = new Array(n).fill(-1);
232
- let i = 0, j = 0;
233
- while (i < n && j < m) {
234
- if (a[i] === b[j]) {
235
- aMatch[i] = j;
236
- i++;
237
- j++;
276
+ const posInB = new Map();
277
+ for (let j = 0; j < m; j++)
278
+ posInB.set(b[j], j);
279
+ const ai = [], bj = []; // a-index / b-position of common keys, in a-order
280
+ for (let i = 0; i < n; i++) {
281
+ const j = posInB.get(a[i]);
282
+ if (j !== undefined) {
283
+ ai.push(i);
284
+ bj.push(j);
238
285
  }
239
- else if (dp[i + 1][j] >= dp[i][j + 1])
240
- i++;
241
- else
242
- j++;
286
+ }
287
+ // Patience LIS over bj (strictly increasing) with predecessor links.
288
+ const tails = []; // index into bj of the smallest tail per LIS length
289
+ const prev = new Array(bj.length).fill(-1);
290
+ for (let x = 0; x < bj.length; x++) {
291
+ let lo = 0, hi = tails.length;
292
+ while (lo < hi) {
293
+ const mid = (lo + hi) >> 1;
294
+ if (bj[tails[mid]] < bj[x])
295
+ lo = mid + 1;
296
+ else
297
+ hi = mid;
298
+ }
299
+ prev[x] = lo > 0 ? tails[lo - 1] : -1;
300
+ tails[lo] = x;
301
+ }
302
+ for (let cur = tails.length ? tails[tails.length - 1] : -1; cur >= 0; cur = prev[cur]) {
303
+ aMatch[ai[cur]] = bj[cur];
243
304
  }
244
305
  return aMatch;
245
306
  }
@@ -289,6 +350,22 @@ function opLine(op) {
289
350
  return `insert <- blob:${op.blob} ${anchorStr(op.anchor)}`;
290
351
  return `move ${op.key} ${anchorStr(op.anchor)}`;
291
352
  }
353
+ // §8 hashes are over "the exact UTF-8 bytes of that version" — which includes
354
+ // its newline style. The sidecar's file-level nl is a SINGLE value that later
355
+ // commits overwrite, so a document whose line endings flipped (LF↔CRLF is
356
+ // routine on Windows) would leave older revisions' recorded hashes
357
+ // unreproducible. Each revision therefore records its own `newline`; for
358
+ // legacy revisions that never recorded one, accept either byte encoding of
359
+ // the reconstructed text — both still pin the CONTENT exactly.
360
+ const nlNamed = (nl) => (nl === "\r\n" ? "crlf" : "lf");
361
+ const nlOf = (name) => name === "crlf" ? "\r\n" : name === "lf" ? "\n" : undefined;
362
+ function hashMatchesRecorded(lf, r, fileNl) {
363
+ const own = nlOf(r.newline);
364
+ if (own)
365
+ return fullHash(lf, own) === r.hash;
366
+ return fullHash(lf, fileNl) === r.hash
367
+ || fullHash(lf, fileNl === "\r\n" ? "\n" : "\r\n") === r.hash;
368
+ }
292
369
  function parseHistory(path) {
293
370
  const { lf, nl } = loadBytes(path);
294
371
  const lines = lf.split("\n");
@@ -315,6 +392,7 @@ function parseHistory(path) {
315
392
  author: attr(b.attrLine, "author"),
316
393
  summary: attr(b.attrLine, "summary"),
317
394
  hash: attr(b.attrLine, "hash") ?? "",
395
+ newline: attr(b.attrLine, "newline"),
318
396
  ops: parseOps(body),
319
397
  });
320
398
  }
@@ -386,6 +464,7 @@ function renderHistory(h, baseName) {
386
464
  r.author ? `author="${r.author}"` : "",
387
465
  r.summary ? `summary="${r.summary}"` : "",
388
466
  `hash="${r.hash}"`,
467
+ r.newline ? `newline="${r.newline}"` : "",
389
468
  ].filter(Boolean).join(" ");
390
469
  parts.push(`=== revision {${at}}\n${r.ops.map(opLine).join("\n")}${r.ops.length ? "\n" : ""}===\n`);
391
470
  // blobs referenced by this revision
@@ -417,9 +496,23 @@ export function commit(o) {
417
496
  if (bytesOf(back, nl).compare(bytesOf(prevContent, nl)) !== 0) {
418
497
  throw new Error("history: reverse patch does NOT round-trip to the previous revision; aborting commit");
419
498
  }
499
+ // Blob ids are minted per-diff (b1, b2, …). Renumber this commit's blobs to
500
+ // start past the highest id already stored, so a later commit never reuses
501
+ // an earlier revision's blob id — an overwrite in the shared store silently
502
+ // corrupts reconstruction of older revisions, whose `replace … <- blob:bN`
503
+ // would then resolve to the wrong (newer) content.
504
+ let maxBlob = 0;
505
+ for (const k of h.blobs.keys()) {
506
+ const mm = /^b(\d+)$/.exec(k);
507
+ if (mm)
508
+ maxBlob = Math.max(maxBlob, Number(mm[1]));
509
+ }
510
+ const remap = new Map(patch.blobs.map((b, i) => [b.id, `b${maxBlob + i + 1}`]));
511
+ patch.ops = patch.ops.map((op) => (op.blob ? { ...op, blob: remap.get(op.blob) } : op));
512
+ patch.blobs = patch.blobs.map((b) => ({ id: remap.get(b.id), payload: b.payload }));
420
513
  for (const b of patch.blobs)
421
514
  h.blobs.set(b.id, b.payload);
422
- h.revisions.set(id, { id, parent: prevId, author: o.author, summary: o.summary, hash, ops: patch.ops });
515
+ h.revisions.set(id, { id, parent: prevId, author: o.author, summary: o.summary, hash, newline: nlNamed(nl), ops: patch.ops });
423
516
  h.keyframes.delete(prevId); // demote previous tip mirror (keyframes at intervals only)
424
517
  h.keyframes.set(id, working);
425
518
  h.current = id;
@@ -428,7 +521,7 @@ export function commit(o) {
428
521
  h = {
429
522
  nl, current: id,
430
523
  keyframes: new Map([[id, working]]),
431
- revisions: new Map([[id, { id, author: o.author, summary: o.summary, hash, ops: [] }]]),
524
+ revisions: new Map([[id, { id, author: o.author, summary: o.summary, hash, newline: nlNamed(nl), ops: [] }]]),
432
525
  blobs: new Map(),
433
526
  };
434
527
  }
@@ -447,17 +540,46 @@ export function verify(historyPath, gemlPath) {
447
540
  catch (e) {
448
541
  errors.push(String(e.message));
449
542
  }
543
+ // Reconstruct every revision INCREMENTALLY. `reconstruct(h, id)` on its own
544
+ // rebuilds each revision from the nearest keyframe, replaying up to O(N) ops
545
+ // each time; calling it once per revision is therefore O(N²·K) and lets a
546
+ // ~91 KB sidecar take ~a minute. Because the chain runs newest->oldest and
547
+ // (for revisions without their own keyframe) nearest-keyframe(i) is always
548
+ // nearest-keyframe(i-1), reconstruct(chain[i]) == applyReverse(reconstruct(
549
+ // chain[i-1]), chain[i-1].ops). So we carry the previous revision's content
550
+ // forward and apply ONE reverse patch per step — O(N·K) overall — while
551
+ // validating the exact same reconstructed bytes for every revision. Whenever
552
+ // that carried base is not trustworthy (a keyframe-less head, or right after
553
+ // a step threw) we fall back to the full `reconstruct`, which reproduces the
554
+ // original's behaviour and error messages verbatim.
555
+ let prevContent = null;
556
+ let prevOps = null;
557
+ let baseValid = false;
450
558
  for (const r of chain) {
451
559
  try {
452
- const content = reconstruct(h, r.id);
453
- const got = fullHash(content, h.nl);
454
- if (got !== r.hash)
455
- errors.push(`revision ${r.id}: reconstructed hash ${got} != recorded ${r.hash}`);
560
+ let content;
561
+ if (h.keyframes.has(r.id)) {
562
+ content = h.keyframes.get(r.id);
563
+ }
564
+ else if (baseValid && prevContent !== null && prevOps !== null) {
565
+ content = applyReverse(prevContent, prevOps, h.blobs);
566
+ }
567
+ else {
568
+ content = reconstruct(h, r.id);
569
+ }
570
+ if (!hashMatchesRecorded(content, r, h.nl)) {
571
+ errors.push(`revision ${r.id}: reconstructed hash ${fullHash(content, nlOf(r.newline) ?? h.nl)} != recorded ${r.hash}`);
572
+ }
573
+ prevContent = content;
574
+ baseValid = true;
456
575
  checked++;
457
576
  }
458
577
  catch (e) {
459
578
  errors.push(`revision ${r.id}: ${e.message}`);
579
+ prevContent = null;
580
+ baseValid = false; // a failed step is not a valid base for the next
460
581
  }
582
+ prevOps = r.ops;
461
583
  }
462
584
  if (gemlPath && existsSync(gemlPath)) {
463
585
  const { lf, nl } = loadBytes(gemlPath);
@@ -505,3 +627,61 @@ export function restore(o) {
505
627
  }
506
628
  return content;
507
629
  }
630
+ /** Is the working file byte-identical to the sidecar's tip revision? False
631
+ * means uncommitted drift (e.g. an earlier commit attempt was refused). */
632
+ export function isCurrent(historyPath, gemlPath) {
633
+ const h = parseHistory(historyPath);
634
+ const tip = h.revisions.get(h.current);
635
+ if (!tip)
636
+ return false;
637
+ const { lf, nl } = loadBytes(gemlPath);
638
+ return fullHash(lf, nl) === tip.hash;
639
+ }
640
+ /** Revisions newest-first, each tagged with the `-N` offset that selects it. */
641
+ export function listRevisions(historyPath) {
642
+ const h = parseHistory(historyPath);
643
+ return chainFrom(h).map((r, i) => ({
644
+ id: r.id, parent: r.parent, author: r.author, summary: r.summary, hash: r.hash,
645
+ offset: i, current: i === 0,
646
+ }));
647
+ }
648
+ /** Resolve a revision selector to its id + reconstructed full text. Selectors:
649
+ * `-N` (N revisions back from current; `-0` is the tip), `latest`/`current`, or
650
+ * an unambiguous id prefix/suffix (the same forms `restore` accepts). */
651
+ export function resolveContent(historyPath, selector) {
652
+ const h = parseHistory(historyPath);
653
+ const chain = chainFrom(h); // chain[0] = current tip
654
+ let id;
655
+ const off = /^-(\d+)$/.exec(selector);
656
+ if (off) {
657
+ const n = Number(off[1]);
658
+ if (n >= chain.length)
659
+ throw new Error(`history: offset -${n} is out of range (only ${chain.length} revision(s))`);
660
+ id = chain[n].id;
661
+ }
662
+ else if (selector === "latest" || selector === "current") {
663
+ id = chain[0].id;
664
+ }
665
+ else {
666
+ const ids = [...h.revisions.keys()];
667
+ const matches = ids.filter((x) => x === selector || x.startsWith(selector) || x.endsWith(selector));
668
+ if (matches.length !== 1)
669
+ throw new Error(`history: revision selector "${selector}" matched ${matches.length} revisions`);
670
+ id = matches[0];
671
+ }
672
+ return { id, text: reconstruct(h, id) };
673
+ }
674
+ /** Walk the chain newest→oldest; return the first revision whose block (as
675
+ * extracted by `pick`) differs from `currentBlock` — i.e. the block's previous
676
+ * *distinct* version, skipping revisions that never touched it. Used by
677
+ * `revert --changed`. `undefined` if no earlier revision changed the block. */
678
+ export function firstChangedContent(historyPath, currentBlock, pick) {
679
+ const h = parseHistory(historyPath);
680
+ for (const r of chainFrom(h)) {
681
+ const text = reconstruct(h, r.id);
682
+ const b = pick(text);
683
+ if (b !== undefined && b !== currentBlock)
684
+ return { id: r.id, text };
685
+ }
686
+ return undefined;
687
+ }
package/dist/inline.d.ts CHANGED
@@ -49,4 +49,5 @@ export interface Ref {
49
49
  export interface RefSink {
50
50
  refs: Ref[];
51
51
  }
52
- export declare function parseInline(s: string, line: number, sink: RefSink): Inline[];
52
+ export declare const META_REF_SRC = "\\{\\{\\s*([A-Za-z_][A-Za-z0-9_-]*)\\s*\\}\\}";
53
+ export declare function parseInline(s: string, line: number, sink: RefSink, depth?: number): Inline[];
package/dist/inline.js CHANGED
@@ -6,7 +6,41 @@
6
6
  // internal/cross-document reference is reported to a `RefSink` so the document
7
7
  // layer can resolve and validate it at build time (§8).
8
8
  import { parseAttrs } from "./attrs.js";
9
- const SCHEME = /^[a-z][a-z0-9+.-]*:/i; // http:, https:, mailto:,
9
+ const MAX_INLINE_NESTING = 100; // cap parseInline<->scanAtoms recursion (R2-7 DoS)
10
+ // §4: the source pattern of a `{{key}}` metadata reference. Owned here as the
11
+ // single definition of what a reference looks like — the parser substitutes it
12
+ // (geml.ts), the serializer escapes it on emit (serialize.ts), and the md
13
+ // converter escapes it on conversion (from-md.ts). Build flagged variants with
14
+ // `new RegExp(META_REF_SRC, flags)`.
15
+ export const META_REF_SRC = "\\{\\{\\s*([A-Za-z_][A-Za-z0-9_-]*)\\s*\\}\\}";
16
+ // §5: URL schemes that may be emitted as an href/src. A destination that names
17
+ // any other scheme (javascript:, vbscript:, data:text/html, file:, …) is a
18
+ // script-injection / local-read vector at the HTML sink, so it is neutralized
19
+ // here at the parse layer — every consumer of the model inherits the guard.
20
+ const SAFE_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
21
+ // The leading `scheme:` (RFC-3986 grammar), lowercased — or null when the
22
+ // destination has none (a relative path, `#anchor`, or cross-document ref).
23
+ function schemeOf(url) {
24
+ // Browsers strip leading/embedded C0 controls and spaces before acting on a
25
+ // URL, so `java\tscript:` and `\x01javascript:` execute as javascript:. Strip
26
+ // every [\x00-\x20] before detecting the scheme so the allowlist can't be
27
+ // evaded that way (R2-2).
28
+ const m = /^([a-z][a-z0-9+.-]*):/i.exec(url.replace(/[\x00-\x20]/g, ""));
29
+ return m ? m[1].toLowerCase() : null;
30
+ }
31
+ // A destination is safe to emit when it has no scheme (relative / anchor /
32
+ // cross-doc), or names an allowlisted scheme. `data:` is permitted only for
33
+ // media and only for `image/*` payloads (never `data:text/html`, which scripts).
34
+ function isSafeUrl(url, allowDataImage = false) {
35
+ const scheme = schemeOf(url);
36
+ if (scheme === null)
37
+ return true;
38
+ if (SAFE_SCHEMES.has(scheme))
39
+ return true;
40
+ if (allowDataImage && scheme === "data")
41
+ return /^\s*data:image\//i.test(url);
42
+ return false;
43
+ }
10
44
  // §5.1: when `as` is omitted, infer the media kind from the source extension.
11
45
  const VIDEO_EXT = /\.(mp4|webm|mov|m4v|ogv|mkv)(?:[?#].*)?$/i;
12
46
  const AUDIO_EXT = /\.(mp3|wav|ogg|oga|m4a|flac|aac|opus)(?:[?#].*)?$/i;
@@ -23,8 +57,12 @@ function inferAs(src) {
23
57
  // Classify a link/image destination into {href|doc, anchor}.
24
58
  function classifyDest(dest) {
25
59
  const d = dest.trim();
26
- if (SCHEME.test(d))
27
- return { href: d };
60
+ if (schemeOf(d) !== null) {
61
+ // Scheme-bearing destination: emit as an href only if the scheme is
62
+ // allowlisted; otherwise drop it entirely so the link renders inert
63
+ // (render() defaults a hrefless link to `#`, keeping the visible text).
64
+ return isSafeUrl(d) ? { href: d } : {};
65
+ }
28
66
  const hash = d.indexOf("#");
29
67
  if (hash === 0)
30
68
  return { anchor: d.slice(1) };
@@ -82,7 +120,7 @@ function readAttrs(s, i) {
82
120
  // Phase A: pull out high-priority atoms (escapes, code, math, media, links,
83
121
  // auto-refs, footnotes, hard breaks). Everything else is left as text runs for
84
122
  // phase B (emphasis). Children of links are fully re-parsed.
85
- function scanAtoms(s, line, sink) {
123
+ function scanAtoms(s, line, sink, depth = 0) {
86
124
  const out = [];
87
125
  let buf = "";
88
126
  const flush = () => { if (buf) {
@@ -150,8 +188,14 @@ function scanAtoms(s, line, sink) {
150
188
  if (label && paren) {
151
189
  const a = readAttrs(s, paren.end);
152
190
  const attrObj = a ? a.attrs : { classes: [], attrs: {} };
191
+ // Media src bypasses classifyDest, so guard the scheme here: a disallowed
192
+ // scheme (javascript:, data:text/html, …) is neutralized to an empty src
193
+ // so the HTML sink cannot load/execute it. Relative paths, http(s), and
194
+ // image/* data URIs pass through.
195
+ const rawSrc = paren.content.trim();
196
+ const src = isSafeUrl(rawSrc, true) ? rawSrc : "";
153
197
  const node = {
154
- type: "image", alt: label.content, src: paren.content.trim(), attrs: attrObj.attrs,
198
+ type: "image", alt: label.content, src, attrs: attrObj.attrs,
155
199
  };
156
200
  const as = attrObj.attrs["as"];
157
201
  if (typeof as === "string")
@@ -207,7 +251,7 @@ function scanAtoms(s, line, sink) {
207
251
  const dest = classifyDest(paren.content);
208
252
  const node = {
209
253
  type: "link",
210
- children: parseInline(label.content, line, sink),
254
+ children: parseInline(label.content, line, sink, depth + 1),
211
255
  attrs: attrObj.attrs,
212
256
  };
213
257
  if (dest.href)
@@ -405,8 +449,17 @@ function mergeText(ns) {
405
449
  }
406
450
  return out;
407
451
  }
408
- export function parseInline(s, line, sink) {
409
- const atoms = scanAtoms(s, line, sink);
452
+ export function parseInline(s, line, sink, depth = 0) {
453
+ if (depth > MAX_INLINE_NESTING) {
454
+ // Pathological nesting (thousands of nested link labels) would overflow the
455
+ // call stack (R2-7). Degrade the over-deep content to text — emphasis only,
456
+ // no further link recursion — and flag it; never throw RangeError.
457
+ const diags = sink.diags;
458
+ if (Array.isArray(diags) && !diags.some((d) => d.message.startsWith("inline nesting too deep")))
459
+ diags.push({ severity: "error", message: `inline nesting too deep (max ${MAX_INLINE_NESTING})`, line });
460
+ return mergeText(emphasize(s));
461
+ }
462
+ const atoms = scanAtoms(s, line, sink, depth);
410
463
  const out = [];
411
464
  for (const a of atoms) {
412
465
  if (typeof a === "string")
@@ -0,0 +1,3 @@
1
+ import { type Document } from "./geml.js";
2
+ import { type RenderOptions } from "./render.js";
3
+ export declare function renderHtml(doc: Document, opts?: RenderOptions): string;
@@ -0,0 +1,95 @@
1
+ // GEML CLI HTML export — the standalone, self-contained page.
2
+ //
3
+ // This is the CLI-only entry point that wraps a rendered document body in a
4
+ // full HTML page shell. Math (KaTeX) and Mermaid load from a CDN, and only when
5
+ // the document actually uses them, so a document of prose, tables and charts is
6
+ // fully self-contained with zero network.
7
+ //
8
+ // It lives in its OWN module (separate from ./render) so that consumers who only
9
+ // need the in-browser graph runtime (buildCodeGraph/codeGraphRuntime/
10
+ // codeGraphWaves) — notably the browser-extension viewer bundle — never pull in
11
+ // these CDN/remote-script string literals. The Chrome Web Store scanner rejects
12
+ // bundles that contain remotely-hosted-code references.
13
+ import { CSS, JS, CODE_GRAPH_JS, RenderCtx, esc, escAttr, } from "./render.js";
14
+ function page(title, body, ctx, source) {
15
+ const mathHead = ctx.usedMath
16
+ ? `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">\n` +
17
+ `<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>\n` +
18
+ `<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" onload="renderMathInElement(document.body,{delimiters:[{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}]})"></script>\n`
19
+ : "";
20
+ const mermaidHead = ctx.usedMermaid
21
+ ? `<script type="module">import m from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";m.initialize({startOnLoad:true});</script>\n`
22
+ : "";
23
+ const footer = source
24
+ ? `<footer class="geml-footer">Rendered from <code>${esc(source)}</code> by the GEML runtime. Tables are sortable and filterable; the chart is inline SVG drawn from its bound table.</footer>`
25
+ : "";
26
+ // Live enhancement for served pages: attach _cgView loaders after the
27
+ // static bootstrap has drawn. The runtime reads the hook lazily, so late
28
+ // binding works with no redraw; if this module never loads (offline copy,
29
+ // old browser), the page simply stays static. The parser dist imports
30
+ // node:* for its CLI paths — an import map points those at the served stub
31
+ // (same trick as the viewer's esbuild alias), and the process shim must be
32
+ // in place BEFORE the modules evaluate, hence the dynamic import().
33
+ const wantLive = ctx.usedCodeGraph && !!ctx.opts.liveGraph;
34
+ const lg = wantLive ? escAttr(ctx.opts.liveGraph) : "";
35
+ const importMap = wantLive
36
+ ? `<script type="importmap">{"imports":{"node:fs":"${lg}_node-stub.js","node:path":"${lg}_node-stub.js","node:crypto":"${lg}_node-stub.js","node:url":"${lg}_node-stub.js","node:child_process":"${lg}_node-stub.js"}}</script>\n`
37
+ : "";
38
+ const liveJs = wantLive
39
+ ? `<script type="module">
40
+ globalThis.process ??= { argv: [], env: {} };
41
+ const { parse } = await import("${lg}geml.js");
42
+ const { codeGraphWaves } = await import("${lg}render.js");
43
+ const w = codeGraphWaves(async (rel) => {
44
+ try { const r = await fetch(rel, { cache: "no-cache" }); return r.ok ? await r.text() : null; } catch { return null; }
45
+ }, parse);
46
+ for (const m of document.querySelectorAll(".cg-mount[data-start]")) {
47
+ const start = m.getAttribute("data-start");
48
+ m._cgView = async (view) => {
49
+ // A directed view builds from the node's OWN document (its meta names the
50
+ // module and graph-depth); {doc} opens that document; else the mount's.
51
+ const src = view && view.doc ? view.doc
52
+ : view && view.node ? view.node.slice(0, view.node.lastIndexOf("#"))
53
+ : start;
54
+ const r = await w.build(src, view && view.doc ? undefined : view);
55
+ return r.error !== undefined ? null : r.data;
56
+ };
57
+ }
58
+ </script>\n`
59
+ : "";
60
+ return `<!doctype html>
61
+ <html lang="en">
62
+ <head>
63
+ <meta charset="utf-8">
64
+ <meta name="viewport" content="width=device-width, initial-scale=1">
65
+ <title>${esc(title)}</title>
66
+ <style>${CSS}</style>
67
+ ${importMap}${mathHead}${mermaidHead}</head>
68
+ <body>
69
+ <main>
70
+ ${body}
71
+ </main>
72
+ ${footer}
73
+ <script>${JS}</script>
74
+ ${ctx.usedCodeGraph ? `<script>${CODE_GRAPH_JS}</script>\n` : ""}${liveJs}</body>
75
+ </html>
76
+ `;
77
+ }
78
+ export function renderHtml(doc, opts = {}) {
79
+ const ctx = new RenderCtx(doc, opts);
80
+ let body = doc.children.map((b) => ctx.block(b)).filter((s) => s !== "").join("\n");
81
+ // Codemap scenario ① (GEP-0003): a codemap document (meta declares module=
82
+ // or container=, plus an entry surface) IS the graph data — offer the layered
83
+ // method-flow view at the top, an implicit self-embed.
84
+ const meta = doc.children.find((b) => b.kind === "block" && b.type === "meta" && b.data);
85
+ const md = meta?.data ?? {};
86
+ if ((md["module"] !== undefined || md["container"] !== undefined)
87
+ && opts.loadDoc && opts.parseDoc && opts.source) {
88
+ const cap = md["entry"] !== undefined || md["container"] !== undefined
89
+ ? `layered method flow — roots from this document's <code>entry</code>`
90
+ : `layered method flow — roots: in-degree-zero methods (no <code>entry</code> declared)`;
91
+ body = ctx.codeGraphFigure(opts.source, "", `<figcaption>${cap}</figcaption>`) + "\n" + body;
92
+ }
93
+ const title = opts.title ?? ctx.docTitle() ?? "GEML document";
94
+ return page(title, body, ctx, opts.source);
95
+ }