@geml/geml 1.4.6 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
  };
@@ -288,8 +315,13 @@ function scanAtoms(s, line, sink, depth = 0) {
288
315
  // guesswork. Delimiters pair only *within* one text run: they never reach across
289
316
  // a code span, inline math, a link or image (atoms from phase A), or a block
290
317
  // boundary. Any delimiter left unpaired is literal text.
291
- const ASCII_PUNCT = /[!-\/:-@\[-`{-~]/;
292
- const isPunct = (c) => c !== undefined && ASCII_PUNCT.test(c);
318
+ // Unicode punctuation, not just ASCII (§5.3). With an ASCII-only test, `“` and
319
+ // `,` count as ordinary letters, and a run hugged by CJK punctuation on the
320
+ // outside and ASCII punctuation on the inside stops flanking: `“*(foo)*”` loses
321
+ // its emphasis. CommonMark's rule is Unicode-wide, and the algorithm here is
322
+ // meant to be that rule restricted to `*` and `~~` — not a narrower one.
323
+ const PUNCT = /[\p{P}\p{S}]/u;
324
+ const isPunct = (c) => c !== undefined && PUNCT.test(c);
293
325
  const isWS = (c) => c === undefined || /\s/.test(c);
294
326
  // Left/right-flanking for a delimiter run, given the chars on either side.
295
327
  function flank(before, after) {
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,
@@ -611,25 +619,25 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
611
619
  // ---------------------------------------------------------------------------
612
620
  // Entry
613
621
  // ---------------------------------------------------------------------------
614
- export const MCP_USAGE = `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
615
-
616
- Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0), plus the
617
- read-only code-graph tools when the root holds a code graph.
618
-
619
- --root <dir> REQUIRED. Root directory holding the .geml documents.
620
- Relative paths resolve against the server process's CWD,
621
- which the CLIENT chooses — pass an absolute path.
622
- Every path a client names is confined to this directory;
623
- a client cannot widen or override it.
624
- --graph <dir> Code-graph directory, inside --root. Defaults to
625
- <root>/.geml-code-graph when that holds an index.geml.
626
- With no graph, the code-graph tools are not served
627
- at all (a client sees only the document tools).
628
- --no-history Do not auto-commit a .gemlhistory revision before each
629
- write. Default is to commit, so geml_revert always
630
- has a revision to undo to.
631
-
632
- Register with a client:
622
+ export const MCP_USAGE = `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
623
+
624
+ Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0), plus the
625
+ read-only code-graph tools when the root holds a code graph.
626
+
627
+ --root <dir> REQUIRED. Root directory holding the .geml documents.
628
+ Relative paths resolve against the server process's CWD,
629
+ which the CLIENT chooses — pass an absolute path.
630
+ Every path a client names is confined to this directory;
631
+ a client cannot widen or override it.
632
+ --graph <dir> Code-graph directory, inside --root. Defaults to
633
+ <root>/.geml-code-graph when that holds an index.geml.
634
+ With no graph, the code-graph tools are not served
635
+ at all (a client sees only the document tools).
636
+ --no-history Do not auto-commit a .gemlhistory revision before each
637
+ write. Default is to commit, so geml_revert always
638
+ has a revision to undo to.
639
+
640
+ Register with a client:
633
641
  claude mcp add geml -- geml mcp --root /abs/path/to/repo`;
634
642
  export function parseArgs(args) {
635
643
  let root;
@@ -36,43 +36,43 @@ function page(title, body, ctx, source) {
36
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
37
  : "";
38
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
- }
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
58
  </script>\n`
59
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>
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
76
  `;
77
77
  }
78
78
  export function renderHtml(doc, opts = {}) {
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;