@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/LICENSE +21 -21
- package/README.md +109 -72
- package/codemap/adapters/crg.mjs +120 -0
- package/codemap/adapters/joern.mjs +131 -0
- package/codemap/adapters/scip.mjs +658 -0
- package/codemap/browser-stub.mjs +29 -0
- package/codemap/build.mjs +579 -0
- package/codemap/detect.mjs +399 -0
- package/codemap/emit.mjs +432 -0
- package/codemap/entries.mjs +129 -0
- package/codemap/exclude.mjs +52 -0
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/joern-export.sc +83 -0
- package/codemap/mcp-server.mjs +172 -0
- package/codemap/normalize.mjs +272 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +310 -0
- package/codemap/render-all.mjs +64 -0
- package/codemap/serve.mjs +578 -0
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +143 -0
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +7 -1
- package/dist/geml.js +700 -52
- package/dist/history.d.ts +30 -0
- package/dist/history.js +212 -32
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +91 -2
- package/dist/render.js +1916 -50
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +5 -3
- package/package.json +63 -54
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
|
|
85
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 =
|
|
206
|
-
|
|
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 =
|
|
210
|
-
|
|
231
|
+
const u = resolveSnap(op.key);
|
|
232
|
+
rangeEdits.push({ start: u.start, endExcl: u.endExcl, repl: blob(op.blob) });
|
|
211
233
|
}
|
|
212
|
-
else
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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
|
|
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 (
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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,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
|
+
}
|