@geml/geml 1.4.5 → 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/codemap/mcp-server.mjs +1 -1
- package/codemap/render-all.mjs +16 -3
- package/codemap/serve.mjs +8 -1
- package/dist/diagnostics.d.ts +1 -1
- package/dist/diagnostics.js +10 -0
- package/dist/geml.d.ts +4 -0
- package/dist/geml.js +529 -57
- package/dist/inline.d.ts +20 -0
- package/dist/inline.js +29 -2
- package/dist/mcp.js +13 -5
- package/dist/render.d.ts +19 -0
- package/dist/render.js +349 -24
- package/dist/serialize.js +9 -1
- package/dist/table.js +5 -3
- package/dist/to-md.js +13 -2
- package/package.json +5 -2
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 {…}.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
20
27
|
}
|
|
21
28
|
export function escAttr(s) {
|
|
22
29
|
return esc(s).replace(/"/g, """);
|
|
@@ -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
|
-
|
|
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}` :
|
|
112
|
-
|
|
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 "
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 "
|
|
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 =
|
|
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 `${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
|
-
|
|
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
|