@geml/geml 1.4.6 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/inline.d.ts CHANGED
@@ -36,6 +36,10 @@ export type Inline = {
36
36
  type: "autoref";
37
37
  anchor: string;
38
38
  doc?: string;
39
+ } | {
40
+ type: "project";
41
+ anchor: string;
42
+ doc?: string;
39
43
  } | {
40
44
  type: "footnote";
41
45
  ref: string;
@@ -48,6 +52,22 @@ export interface Ref {
48
52
  }
49
53
  export interface RefSink {
50
54
  refs: Ref[];
55
+ embeds?: {
56
+ doc: string;
57
+ anchor?: string;
58
+ line: number;
59
+ }[];
60
+ mediaDocTargets?: {
61
+ src: string;
62
+ line: number;
63
+ }[];
64
+ projections?: {
65
+ doc?: string;
66
+ anchor: string;
67
+ line: number;
68
+ }[];
51
69
  }
52
70
  export declare const META_REF_SRC = "\\{\\{\\s*([A-Za-z_][A-Za-z0-9_-]*)\\s*\\}\\}";
71
+ export declare function schemeOf(url: string): string | null;
72
+ export declare function isSafeUrl(url: string, allowDataImage?: boolean): boolean;
53
73
  export declare function parseInline(s: string, line: number, sink: RefSink, depth?: number): Inline[];
package/dist/inline.js CHANGED
@@ -20,7 +20,7 @@ export const META_REF_SRC = "\\{\\{\\s*([A-Za-z_][A-Za-z0-9_-]*)\\s*\\}\\}";
20
20
  const SAFE_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
21
21
  // The leading `scheme:` (RFC-3986 grammar), lowercased — or null when the
22
22
  // destination has none (a relative path, `#anchor`, or cross-document ref).
23
- function schemeOf(url) {
23
+ export function schemeOf(url) {
24
24
  // Browsers strip leading/embedded C0 controls and spaces before acting on a
25
25
  // URL, so `java\tscript:` and `\x01javascript:` execute as javascript:. Strip
26
26
  // every [\x00-\x20] before detecting the scheme so the allowlist can't be
@@ -31,7 +31,7 @@ function schemeOf(url) {
31
31
  // A destination is safe to emit when it has no scheme (relative / anchor /
32
32
  // cross-doc), or names an allowlisted scheme. `data:` is permitted only for
33
33
  // media and only for `image/*` payloads (never `data:text/html`, which scripts).
34
- function isSafeUrl(url, allowDataImage = false) {
34
+ export function isSafeUrl(url, allowDataImage = false) {
35
35
  const scheme = schemeOf(url);
36
36
  if (scheme === null)
37
37
  return true;
@@ -182,6 +182,29 @@ function scanAtoms(s, line, sink, depth = 0) {
182
182
  continue;
183
183
  }
184
184
  // §5.3(2): image ![alt](src){…}.
185
+ // §5.3 precedence: inline projection `![[…]]` is tried BEFORE the image atom.
186
+ // Otherwise `![[#x]]` reads as an image whose label happens to be `[#x]`, and
187
+ // `![[#x]](y)` would be claimed whole — the parenthesis run has to stay
188
+ // literal text, which is what this ordering pins.
189
+ if (c === "!" && s[i + 1] === "[" && s[i + 2] === "[") {
190
+ const inner = readBracket(s, i + 2); // the inner [...] after `![`
191
+ if (inner && s[inner.end] === "]") {
192
+ const { doc, anchor } = classifyDest(inner.content.trim());
193
+ if (anchor) {
194
+ flush();
195
+ const node = { type: "project", anchor };
196
+ if (doc)
197
+ node.doc = doc;
198
+ out.push(node);
199
+ // Validated by the same §8 resolver as any reference; the target's TYPE
200
+ // is checked separately, since only inline content can be projected.
201
+ sink.refs.push({ kind: doc ? "cross" : "autoref", doc, anchor, line });
202
+ (sink.projections ??= []).push(doc === undefined ? { anchor, line } : { doc, anchor, line });
203
+ i = inner.end + 1;
204
+ continue;
205
+ }
206
+ }
207
+ }
185
208
  if (c === "!" && s[i + 1] === "[") {
186
209
  const label = readBracket(s, i + 1);
187
210
  const paren = label ? readParen(s, label.end) : null;
@@ -194,6 +217,10 @@ function scanAtoms(s, line, sink, depth = 0) {
194
217
  // image/* data URIs pass through.
195
218
  const rawSrc = paren.content.trim();
196
219
  const src = isSafeUrl(rawSrc, true) ? rawSrc : "";
220
+ // A GEML target here means the author wanted a transclusion, which is a
221
+ // block: `=== embed`. Recorded for the caller to report.
222
+ if (/\.geml(#|$)/i.test(src))
223
+ (sink.mediaDocTargets ??= []).push({ src, line });
197
224
  const node = {
198
225
  type: "image", alt: label.content, src, attrs: attrObj.attrs,
199
226
  };
package/dist/mcp.js CHANGED
@@ -163,7 +163,7 @@ function applyWrite(spec) {
163
163
  const before = readFileSync(real, "utf8");
164
164
  const root = realpathSync(OPTS.root);
165
165
  const errorKey = (d) => `${d.code}:${d.message}`;
166
- const preexisting = new Set(parse(before, { resolveDoc: docResolver(root) }).diagnostics
166
+ const preexisting = new Set(parse(before, { resolveDoc: docResolver(root, real) }).diagnostics
167
167
  .filter((d) => d.severity === "error")
168
168
  .map(errorKey));
169
169
  // 1. Produce the mutated document WITHOUT touching the file. `--json` makes
@@ -190,7 +190,7 @@ function applyWrite(spec) {
190
190
  }
191
191
  // 2. Validate the RESULT independently of the CLI. This is what catches the
192
192
  // tools the CLI lets through — deleting a referenced block, above all.
193
- const diags = parse(after, { resolveDoc: docResolver(root) }).diagnostics;
193
+ const diags = parse(after, { resolveDoc: docResolver(root, real) }).diagnostics;
194
194
  let blocking = diags.filter((d) => d.severity === "error" && !preexisting.has(errorKey(d)));
195
195
  if (spec.danglingIsWarning) {
196
196
  blocking = blocking.filter((d) => d.code !== "unresolved-reference" && d.code !== "unresolved-footnote");
@@ -221,10 +221,18 @@ function snapshot(realPath, summary) {
221
221
  return undefined;
222
222
  }
223
223
  }
224
- function docResolver(root) {
224
+ // A cross-document reference resolves FROM THE DOCUMENT'S OWN DIRECTORY, which is
225
+ // what the CLI resolver and the renderer both do. Resolving from the server root
226
+ // instead made the validator inspect a different file than the renderer expands:
227
+ // for `sub/a.geml` naming `b.geml`, it validated `<root>/b.geml` while the render
228
+ // pulled in `<root>/sub/b.geml` — phantom errors in one direction, and in the other
229
+ // a write signed off against a file that was never the target. The root stays the
230
+ // confinement boundary.
231
+ function docResolver(root, fromFile) {
232
+ const base = dirname(fromFile);
225
233
  return (doc) => {
226
234
  try {
227
- const target = realpathSync(resolve(root, doc));
235
+ const target = realpathSync(resolve(base, doc));
228
236
  if (target !== root && !target.startsWith(root + sep))
229
237
  return null;
230
238
  return readFileSync(target, "utf8");
@@ -283,7 +291,7 @@ export const TOOLS = [
283
291
  run: (args) => {
284
292
  const real = resolveInRoot(args.file);
285
293
  const root = resolveRoot(args.root);
286
- const doc = parse(readFileSync(real, "utf8"), { resolveDoc: docResolver(root) });
294
+ const doc = parse(readFileSync(real, "utf8"), { resolveDoc: docResolver(root, real) });
287
295
  const errors = doc.diagnostics.filter((d) => d.severity === "error").length;
288
296
  return {
289
297
  ok: errors === 0,
package/dist/render.d.ts CHANGED
@@ -18,6 +18,13 @@ export declare class RenderCtx {
18
18
  usedMermaid: boolean;
19
19
  usedCodeGraph: boolean;
20
20
  private renderDepth;
21
+ private embedStack;
22
+ private embedDocs;
23
+ private embedCount;
24
+ private embedBytes;
25
+ private embedCache;
26
+ private budgetExhausted;
27
+ private loadChildren;
21
28
  labels: Map<string, string>;
22
29
  constructor(doc: Document, opts?: RenderOptions);
23
30
  get isCodemapDoc(): boolean;
@@ -25,6 +32,18 @@ export declare class RenderCtx {
25
32
  docTitle(): string | undefined;
26
33
  inlines(ns: Inline[]): string;
27
34
  private inline;
35
+ private transclude;
36
+ private projectInline;
37
+ private projectFallback;
38
+ private get currentDocRel();
39
+ private get currentDocChildren();
40
+ private currentLabels;
41
+ private idAttr;
42
+ private remoteLabels;
43
+ private remoteLabel;
44
+ private fragmentHref;
45
+ private transclusionWrap;
46
+ private transclusionFallback;
28
47
  private media;
29
48
  private link;
30
49
  block(b: Block): string;
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>`;
116
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();
360
+ }
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>`;
117
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") {
package/dist/serialize.js CHANGED
@@ -124,6 +124,7 @@ function serInline(n, esc) {
124
124
  case "image": return `![${n.alt}](${n.src})${serAttrs({ attrs: n.attrs })}`;
125
125
  case "link": return `[${serSeq(n.children, esc)}](${linkDest(n)})${serAttrs({ attrs: n.attrs })}`;
126
126
  case "autoref": return `[[${n.doc !== undefined ? `${n.doc}#${n.anchor}` : `#${n.anchor}`}]]`;
127
+ case "project": return `![[${n.doc !== undefined ? `${n.doc}#${n.anchor}` : `#${n.anchor}`}]]`;
127
128
  case "footnote": return `[^${n.ref}]`;
128
129
  }
129
130
  }
@@ -138,7 +139,14 @@ function serInlines(ns) {
138
139
  const lazy = serSeq(ns, false);
139
140
  if (JSON.stringify(parseInline(lazy, 0, { refs: [] })) === JSON.stringify(ns))
140
141
  return lazy;
141
- return serSeq(ns, true);
142
+ const escaped = serSeq(ns, true);
143
+ if (JSON.stringify(parseInline(escaped, 0, { refs: [] })) === JSON.stringify(ns))
144
+ return escaped;
145
+ // Neither form round-trips on its own. The case this exists for: a text run
146
+ // ending in `!` immediately before an auto-reference re-reads as an inline
147
+ // projection (`!` + `[[` is one atom since §5.3), so the bang has to be escaped
148
+ // even though nothing about the text itself is a metacharacter.
149
+ return serSeq(ns, true).replace(/!(?=\[\[)/g, "\\!");
142
150
  }
143
151
  // ---------------------------------------------------------------------------
144
152
  // Blocks