@c9up/inker 0.1.6 → 0.1.7

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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/dist/InkerProvider.d.ts +5 -1
  3. package/dist/InkerProvider.d.ts.map +1 -1
  4. package/dist/InkerProvider.js +33 -17
  5. package/dist/InkerProvider.js.map +1 -1
  6. package/dist/InkerRenderError.d.ts +1 -1
  7. package/dist/InkerRenderError.d.ts.map +1 -1
  8. package/dist/InkerRenderError.js.map +1 -1
  9. package/dist/InkerRenderer.d.ts +9 -0
  10. package/dist/InkerRenderer.d.ts.map +1 -1
  11. package/dist/InkerRenderer.js +13 -0
  12. package/dist/InkerRenderer.js.map +1 -1
  13. package/dist/Templates.d.ts +44 -0
  14. package/dist/Templates.d.ts.map +1 -1
  15. package/dist/Templates.js +271 -147
  16. package/dist/Templates.js.map +1 -1
  17. package/dist/globals.d.ts +10 -0
  18. package/dist/globals.d.ts.map +1 -0
  19. package/dist/globals.js +235 -0
  20. package/dist/globals.js.map +1 -0
  21. package/dist/identifierGuards.d.ts +2 -2
  22. package/dist/identifierGuards.js +2 -2
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/loadNapi.d.ts +6 -4
  27. package/dist/loadNapi.d.ts.map +1 -1
  28. package/dist/loadNapi.js +2 -2
  29. package/dist/loadNapi.js.map +1 -1
  30. package/dist/renderNode.d.ts +184 -0
  31. package/dist/renderNode.d.ts.map +1 -0
  32. package/dist/renderNode.js +479 -0
  33. package/dist/renderNode.js.map +1 -0
  34. package/index.darwin-arm64.node +0 -0
  35. package/index.darwin-x64.node +0 -0
  36. package/index.linux-arm64-gnu.node +0 -0
  37. package/index.linux-x64-gnu.node +0 -0
  38. package/index.win32-x64-msvc.node +0 -0
  39. package/package.json +3 -4
  40. package/scripts/copy-napi.mjs +0 -62
  41. package/src/InkerProvider.ts +0 -588
  42. package/src/InkerRenderError.ts +0 -49
  43. package/src/InkerRenderer.ts +0 -55
  44. package/src/SafeString.ts +0 -27
  45. package/src/Templates.ts +0 -1332
  46. package/src/helpers.ts +0 -76
  47. package/src/identifierGuards.ts +0 -49
  48. package/src/index.ts +0 -16
  49. package/src/loadNapi.ts +0 -270
  50. package/src/services/main.ts +0 -56
package/dist/Templates.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as fsPromises from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { EDGE_GLOBAL_NAMES } from "./globals.js";
4
5
  import { InkerRenderError } from "./InkerRenderError.js";
5
6
  import { PROTOTYPE_POLLUTION_KEYS, RESERVED_BINDING_NAMES, } from "./identifierGuards.js";
6
7
  import { getNative, napiThrowToInker, } from "./loadNapi.js";
7
- import { SafeString } from "./SafeString.js";
8
+ import { collectSections, renderNodeTree, } from "./renderNode.js";
8
9
  const HELPER_NAME_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
9
10
  // P13 — Windows-reserved device basenames. Refused on every platform for
10
11
  // portability: a template named `con.inker` would resolve to the Windows
@@ -38,6 +39,36 @@ const VALID_CACHE_MODES = new Set([
38
39
  "mtime",
39
40
  "never",
40
41
  ]);
42
+ // Built-in block/directive keywords a custom tag may not shadow (registerTag).
43
+ // The parser already ignores these as custom tags; rejecting them here makes the
44
+ // collision loud instead of silently inert.
45
+ // MUST mirror the lexer's `is_block_keyword` set (crates/inker-engine/src/lex.rs)
46
+ // exactly — a name the lexer treats as a built-in but that is missing here would
47
+ // pass registration and then sit silently inert (its `@name` never becomes a
48
+ // CustomTag node), the very failure this blocklist exists to make loud.
49
+ const RESERVED_TAG_NAMES = new Set([
50
+ "if",
51
+ "elseif",
52
+ "else",
53
+ "endif",
54
+ "unless",
55
+ "endunless",
56
+ "each",
57
+ "endeach",
58
+ "let",
59
+ "layout",
60
+ "include",
61
+ "includeIf",
62
+ "component",
63
+ "endcomponent",
64
+ "slot",
65
+ "endslot",
66
+ "section",
67
+ "endsection",
68
+ "super",
69
+ "eval",
70
+ "dump",
71
+ ]);
41
72
  function isErrnoException(value) {
42
73
  return (value instanceof Error && typeof Reflect.get(value, "code") === "string");
43
74
  }
@@ -45,7 +76,7 @@ function normalizePartialKey(name) {
45
76
  let key = name;
46
77
  while (key.startsWith("./"))
47
78
  key = key.slice(2);
48
- // T2: refuse an empty key — `{% include './' %}` would otherwise collide
79
+ // T2: refuse an empty key — `@include('./')` would otherwise collide
49
80
  // with the synthetic `<root>/.inker` dotfile path and silently include
50
81
  // (or misreport) an unrelated file. validateName lets a literal `./`
51
82
  // through (no `..`, no NUL, no backslash, length > 0), so the assertion
@@ -98,7 +129,7 @@ function assertSafeCharacters(name) {
98
129
  /**
99
130
  * Reject absolute paths, `..` segments, backslashes, and Windows drive-letter
100
131
  * prefixes — each would bypass the lexical `path.join(root, …)` containment.
101
- * Mirrors parseBlockTag.validatePathName so `{% include %}` and the public
132
+ * Mirrors parseBlockTag.validatePathName so `@include()` and the public
102
133
  * Templates#render entrypoint agree.
103
134
  */
104
135
  function assertSafePathShape(name) {
@@ -114,6 +145,15 @@ function assertSafePathShape(name) {
114
145
  if (/^[A-Za-z]:/.test(name)) {
115
146
  throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name cannot start with a Windows drive-letter prefix; got ${JSON.stringify(name)}`, { templateName: name });
116
147
  }
148
+ // A bare (post-`#splitDisk`) template name never legitimately contains `:`.
149
+ // The drive-letter guard above only catches a leading `[A-Za-z]:`; a residual
150
+ // separator such as `1::b` (digit-led, so it slips that guard) would otherwise
151
+ // reach `path.join` and, on Windows NTFS, be reinterpreted as an alternate-
152
+ // data-stream reference — a cross-platform resolution divergence. `:` is
153
+ // already reserved as the `::` disk separator, so forbid it outright here.
154
+ if (name.includes(":")) {
155
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name cannot contain ':' characters; got ${JSON.stringify(name)}`, { templateName: name });
156
+ }
117
157
  }
118
158
  /**
119
159
  * Refuse Windows-reserved basenames (`con`, `prn`, `aux`, `nul`, `com1`-`com9`,
@@ -129,6 +169,19 @@ function assertNotReservedDeviceName(name) {
129
169
  }
130
170
  }
131
171
  }
172
+ /**
173
+ * Validate a mount disk name (AdonisJS/Edge `edge.mount(name, …)` parity).
174
+ * A disk name is an identifier-shaped label, NOT a path: it must be non-empty
175
+ * and contain only `[A-Za-z0-9_-]`. This forbids `::` (the disk separator),
176
+ * `/` and `\` (path segments), `.`/`..` traversal, and control bytes — a disk
177
+ * name can never itself become a path component that widens containment.
178
+ */
179
+ const DISK_NAME_RE = /^[A-Za-z0-9_-]+$/;
180
+ function assertDiskName(diskName) {
181
+ if (typeof diskName !== "string" || !DISK_NAME_RE.test(diskName)) {
182
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Mount disk name must match ${DISK_NAME_RE} (identifier-shaped, no path separators); got ${JSON.stringify(diskName)}`, { templateName: typeof diskName === "string" ? diskName : undefined });
183
+ }
184
+ }
132
185
  function assertContained(root, absPath, name) {
133
186
  // P12: case-sensitive `startsWith` breaks on APFS/HFS+/NTFS where
134
187
  // `realpath` canonicalises segment casing — a root like
@@ -356,7 +409,7 @@ function validateHelpers(rawHelpers) {
356
409
  if (!(helpers instanceof Map)) {
357
410
  throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates.helpers must be a Map; got ${Object.prototype.toString.call(helpers).slice(8, -1)}`);
358
411
  }
359
- const helperNames = new Set();
412
+ const helperNames = new Set(EDGE_GLOBAL_NAMES);
360
413
  for (const [key, value] of helpers) {
361
414
  // T3: validate the key is a string BEFORE handing it to
362
415
  // HELPER_NAME_RE.test(), which ToString-coerces and throws a raw
@@ -397,6 +450,18 @@ export class Templates {
397
450
  #cacheGeneration = 0;
398
451
  #helpers;
399
452
  #helperNames;
453
+ // Runtime-registered custom tags (Edge `registerTag`). Names here make the
454
+ // parser recognise `@<tagName>(jsArg)` as a `CustomTag` node; the tag's
455
+ // `compile` runs at render time. A LIVE map, like `#helpers` — but because
456
+ // tag names change how a template PARSES, registerTag() invalidates the cache.
457
+ #tags = new Map();
458
+ // Named template "disks" (AdonisJS/Edge `edge.mount(name, dir)` parity).
459
+ // The DEFAULT disk is `#root` (the constructor `root`), addressed by a bare
460
+ // `template` name; a NAMED disk is addressed as `name::template`. Each value
461
+ // is a canonicalised absolute root — containment (assertContained + the
462
+ // #loadAst symlink guard) is enforced against the disk's OWN root, never a
463
+ // shared one, so mounting a package's templates cannot widen traversal.
464
+ #disks = new Map();
400
465
  constructor(options) {
401
466
  this.#root = canonicalizeTemplatesRoot(options.root);
402
467
  const requested = options.cacheMode ?? "auto";
@@ -419,52 +484,160 @@ export class Templates {
419
484
  this.#helpers = helpers;
420
485
  this.#helperNames = helperNames;
421
486
  }
487
+ /**
488
+ * Mount a named templates "disk" (AdonisJS/Edge `edge.mount(name, dir)`
489
+ * parity). Templates in a mounted disk are addressed as `name::template`
490
+ * (including from `@layout()` / `@include()` / `@component()`
491
+ * references); a BARE `template` name always resolves against the default
492
+ * root, exactly like Edge. `dir` is canonicalised (absolute + realpath) the
493
+ * same way the constructor root is, so each disk carries its own containment
494
+ * boundary.
495
+ *
496
+ * Re-mounting a name to the SAME canonicalised root is an idempotent no-op;
497
+ * re-mounting to a DIFFERENT root throws `E_INKER_DISK_COLLISION` (call
498
+ * `unmount` first for an intentional replacement). This is a NAMED deviation
499
+ * from Edge's silent overwrite: this engine is shared process-wide and
500
+ * consumed by multiple integration packages, so an accidental disk-name
501
+ * clash must fail loud rather than silently clobber another package's
502
+ * containment boundary.
503
+ */
504
+ mount(diskName, dir) {
505
+ assertDiskName(diskName);
506
+ const root = canonicalizeTemplatesRoot(dir);
507
+ const existing = this.#disks.get(diskName);
508
+ if (existing !== undefined && existing !== root) {
509
+ throw new InkerRenderError("E_INKER_DISK_COLLISION", `Disk "${diskName}" is already mounted to "${existing}"; refusing to overwrite it with "${root}". Call unmount(${JSON.stringify(diskName)}) first to replace it.`);
510
+ }
511
+ this.#disks.set(diskName, root);
512
+ }
513
+ /**
514
+ * Unmount a named disk (AdonisJS/Edge `edge.unmount(name)` parity). No-op if
515
+ * the disk was never mounted. Does NOT clear the AST cache — cache keys are
516
+ * `(root, absPath)` pairs, so a later re-mount of a different directory
517
+ * resolves under a different root and cannot serve a stale entry, even when
518
+ * two directories canonicalise to overlapping absolute paths.
519
+ */
520
+ unmount(diskName) {
521
+ this.#disks.delete(diskName);
522
+ }
523
+ /**
524
+ * Register a custom tag (AdonisJS/Edge `edge.registerTag` parity). The `tag`
525
+ * definition — `{ tagName, block, seekable, compile(parser, buffer, token) }` —
526
+ * makes the parser recognise `@<tagName>(jsArg)` in every template and emit a
527
+ * `CustomTag` node. At render time inker calls `compile`, whose `buffer`
528
+ * writes the output (`writeRaw` for verbatim markup, `outputExpression` to
529
+ * evaluate a template expression) and whose `token.properties.jsArg` is the
530
+ * verbatim argument source, e.g. an `@svg('icon')` or `@time()` tag.
531
+ *
532
+ * INKER DEVIATION (named): Edge runs `compile` once at compilation (it emits
533
+ * JS); inker parses in Rust and renders by walking the JSON AST, so `compile`
534
+ * runs at RENDER time. Only inline tags (`block: false`) are supported for now.
535
+ *
536
+ * Because tag names change how a template PARSES, registering (or overwriting)
537
+ * a tag clears the AST cache — call `registerTag` during boot, before rendering.
538
+ */
539
+ registerTag(tag) {
540
+ const name = tag?.tagName;
541
+ if (typeof name !== "string" || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
542
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `registerTag — tagName must be a valid identifier (letters, digits, underscore; not starting with a digit); got ${JSON.stringify(name)}`);
543
+ }
544
+ if (RESERVED_TAG_NAMES.has(name)) {
545
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `registerTag({ tagName: '${name}' }) — '${name}' is a built-in inker directive and cannot be overridden`);
546
+ }
547
+ if (typeof tag.compile !== "function") {
548
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `registerTag({ tagName: '${name}' }) — compile must be a function`);
549
+ }
550
+ if (tag.block === true) {
551
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `registerTag({ tagName: '${name}' }) — block custom tags (@${name}…@end${name}) are not supported yet; register an inline tag (block: false)`);
552
+ }
553
+ this.#tags.set(name, tag);
554
+ // A new tag name changes parse output; drop any AST parsed without it.
555
+ this.clearCache();
556
+ }
557
+ /**
558
+ * Split an optionally-namespaced template name into its resolution root and
559
+ * bare (disk-relative) name. `name::path` → the mounted disk's root; a bare
560
+ * `path` → the default root. Unknown disk → loud E_INKER_INVALID_PATH.
561
+ */
562
+ #splitDisk(name) {
563
+ const sep = name.indexOf("::");
564
+ if (sep === -1)
565
+ return { root: this.#root, bare: name };
566
+ const disk = name.slice(0, sep);
567
+ const bare = name.slice(sep + 2);
568
+ const root = this.#disks.get(disk);
569
+ if (root === undefined) {
570
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Unknown templates disk '${disk}' in '${name}' — mount it with Templates#mount('${disk}', dir) first`, { templateName: name });
571
+ }
572
+ return { root, bare };
573
+ }
574
+ /**
575
+ * Resolve an (optionally `disk::`-prefixed) template name to its disk root,
576
+ * validated bare name, and absolute `.inker` path — with per-disk
577
+ * containment. `prefix` is prepended to the bare name AFTER the disk split
578
+ * (used for `components/` so `disk::button` → `<disk>/components/button`).
579
+ */
580
+ #resolveTemplateFile(name, prefix = "") {
581
+ const { root, bare } = this.#splitDisk(name);
582
+ const validated = validateName(`${prefix}${bare}`);
583
+ const absPath = path.join(root, `${validated}.inker`);
584
+ assertContained(root, absPath, validated);
585
+ return { root, validated, absPath };
586
+ }
422
587
  async render(name, data) {
423
- const validated = validateName(name);
424
- const absPath = path.join(this.#root, `${validated}.inker`);
425
- assertContained(this.#root, absPath, validated);
426
- const entryAst = await this.#loadAst(absPath, validated);
588
+ const { root, validated, absPath } = this.#resolveTemplateFile(name);
589
+ // Validate the data tree (rejects NaN / ±Infinity / out-of-range bigint /
590
+ // sparse holes / circular refs) — the Node renderer evaluates the ORIGINAL
591
+ // data in V8 (Maps/Sets intact), so `encodeData`'s result is discarded and
592
+ // only its guard side-effects are kept.
593
+ encodeData(data);
594
+ const entryAst = await this.#loadAst(absPath, validated, root);
427
595
  const composed = await this.#compose(entryAst, validated, absPath, new Set([absPath]));
428
- const native = getNative();
429
- const encoded = encodeData(data);
430
- const partials = Object.fromEntries(composed.partialAsts);
431
- const components = Object.fromEntries(composed.componentAsts);
432
- // Body pass collect helper invocations in render order, invoke them
433
- // TS-side, then render consuming the resolved tape (ADR-007 as adapted
434
- // for 55.1: collect→invoke→render, no V8 callback).
435
- const bodyCtx = {
436
- partials,
437
- components,
438
- bodyHtml: undefined,
439
- templateName: validated,
440
- templatePath: absPath,
441
- };
442
- const bodyTape = callNative(() => native.collectInvocations(composed.bodyAst, encoded, bodyCtx));
443
- const bodyResolved = this.#invokeHelpers(bodyTape, validated, absPath);
444
- const bodyHtml = callNative(() => native.renderAst(composed.bodyAst, encoded, bodyResolved, bodyCtx));
596
+ // Node renderer (62-2 pivot): convert the loaded AST handles to JSON node
597
+ // lists and evaluate every expression in Node's own V8 with the helpers in
598
+ // scope (Edge model — no tape, no QuickJS, no FFI).
599
+ const partials = new Map();
600
+ for (const [key, handle] of composed.partialAsts) {
601
+ partials.set(key, this.#astNodes(handle));
602
+ }
603
+ const components = new Map();
604
+ for (const [key, handle] of composed.componentAsts) {
605
+ components.set(key, this.#astNodes(handle));
606
+ }
607
+ const childNodes = this.#astNodes(composed.bodyAst);
608
+ const baseCtx = { partials, components, tags: this.#tags };
609
+ // No layout → render the child directly (`@section`s render inline).
445
610
  if (composed.layoutAst === undefined) {
446
- return bodyHtml;
447
- }
448
- const layoutAst = composed.layoutAst;
449
- const layoutName = composed.layoutName ?? validated;
450
- const layoutPath = composed.layoutAbsPath ?? absPath;
451
- // Layout collect ctx omits bodyHtml (slots carry no helpers); render ctx
452
- // injects it. Both walk identically so the tape aligns.
453
- const layoutTape = callNative(() => native.collectInvocations(layoutAst, encoded, {
454
- partials,
455
- components,
456
- bodyHtml: undefined,
457
- templateName: layoutName,
458
- templatePath: layoutPath,
459
- }));
460
- const layoutResolved = this.#invokeHelpers(layoutTape, layoutName, layoutPath);
461
- return callNative(() => native.renderAst(layoutAst, encoded, layoutResolved, {
462
- partials,
463
- components,
611
+ return renderNodeTree(childNodes, data, this.#helpers, baseCtx);
612
+ }
613
+ // With a layout: separate the child's `@section` fills from the default
614
+ // body, render each section (with `@super` = the layout's default for it),
615
+ // and inject them at the layout's matching yields (62-3).
616
+ const { sections: childSections, body: childBody } = collectSections(childNodes);
617
+ const bodyHtml = renderNodeTree(childBody, data, this.#helpers, baseCtx);
618
+ const layoutNodes = this.#astNodes(composed.layoutAst);
619
+ const { sections: layoutDefaults } = collectSections(layoutNodes);
620
+ const sections = new Map();
621
+ for (const [name, sectionNodes] of childSections) {
622
+ const layoutDefault = layoutDefaults.get(name);
623
+ const superHtml = layoutDefault !== undefined
624
+ ? renderNodeTree(layoutDefault, data, this.#helpers, baseCtx)
625
+ : "";
626
+ sections.set(name, renderNodeTree(sectionNodes, data, this.#helpers, {
627
+ ...baseCtx,
628
+ superHtml,
629
+ }));
630
+ }
631
+ return renderNodeTree(layoutNodes, data, this.#helpers, {
632
+ ...baseCtx,
464
633
  bodyHtml,
465
- templateName: layoutName,
466
- templatePath: layoutPath,
467
- }));
634
+ sections,
635
+ });
636
+ }
637
+ /** Convert a parsed AST handle to its JSON node list (62-2 Node renderer). */
638
+ #astNodes(handle) {
639
+ const parsed = JSON.parse(getNative().astToJson(handle));
640
+ return parsed.nodes;
468
641
  }
469
642
  renderString(source, data) {
470
643
  // T4 + P15: strip ALL U+FEFF (BOM) characters, not just a leading one.
@@ -477,82 +650,34 @@ export class Templates {
477
650
  ? source.replace(//g, "")
478
651
  : source;
479
652
  const native = getNative();
480
- const ast = callNative(() => native.parseTemplate(normalisedSource, [...this.#helperNames]));
653
+ const ast = callNative(() => native.parseTemplate(normalisedSource, [...this.#helperNames], [...this.#tags.keys()]));
481
654
  const info = ast.composeInfo;
482
- // The Rust parser separates a leading `{% layout %}` into `ast.layout`
655
+ // The Rust parser separates a leading `@layout()` into `ast.layout`
483
656
  // (not a body node), so `firstDiskNode` won't surface it — check
484
657
  // `hasLayout` explicitly to preserve the renderString disk-required guard.
485
658
  if (info.hasLayout) {
486
- throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve {% layout '${info.layoutName ?? ""}' %} — use Templates#render(name, data) instead`);
659
+ throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve @layout('${info.layoutName ?? ""}') — use Templates#render(name, data) instead`);
487
660
  }
488
661
  const disk = info.firstDiskNode;
489
662
  if (disk !== null && disk !== undefined) {
490
663
  if (disk.kind === "Layout") {
491
- throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve {% layout '${disk.name}' %} — use Templates#render(name, data) instead`);
664
+ throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve @layout('${disk.name}') — use Templates#render(name, data) instead`);
492
665
  }
493
666
  if (disk.kind === "Partial") {
494
- throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve {% include '${disk.name}' %} — use Templates#render(name, data) instead`);
667
+ throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve @include('${disk.name}') — use Templates#render(name, data) instead`);
495
668
  }
496
669
  if (disk.kind === "Component") {
497
- throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve {% component '${disk.name}' %} — use Templates#render(name, data) instead`);
670
+ throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve @component('${disk.name}') — use Templates#render(name, data) instead`);
498
671
  }
499
672
  throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot use {{> ${disk.name} }} outside of a layout — the slot has no parent layout to inject into`);
500
673
  }
501
- const ctx = {
502
- partials: {},
503
- components: {},
504
- bodyHtml: undefined,
505
- templateName: undefined,
506
- templatePath: undefined,
507
- };
508
- const encoded = encodeData(data);
509
- const tape = callNative(() => native.collectInvocations(ast, encoded, ctx));
510
- const resolved = this.#invokeHelpers(tape, undefined, undefined);
511
- return callNative(() => native.renderAst(ast, encoded, resolved, ctx));
512
- }
513
- // Invoke each collected helper TS-side in tape order, producing the resolved
514
- // values the renderer consumes. Mirrors the old `render.ts` Call-arm
515
- // contract: SafeString → raw; null/undefined → ""; non-string/SafeString or
516
- // a throw / thenable → E_INKER_HELPER_THROW (preserving InkerRenderError
517
- // passthrough + cause chain).
518
- #invokeHelpers(tape, templateName, templatePath) {
519
- const out = [];
520
- for (const inv of tape) {
521
- const helper = this.#helpers.get(inv.name);
522
- if (helper === undefined) {
523
- throw new InkerRenderError("E_INKER_UNKNOWN_HELPER", `Helper '${inv.name}' is not registered in this Templates instance`, { templatePath, templateName, expression: inv.name });
524
- }
525
- let result;
526
- let thenProp;
527
- try {
528
- result = helper(...inv.args);
529
- if (result !== null && typeof result === "object") {
530
- thenProp = Reflect.get(result, "then");
531
- }
532
- }
533
- catch (cause) {
534
- if (cause instanceof InkerRenderError)
535
- throw cause;
536
- const message = cause instanceof Error ? cause.message : String(cause);
537
- throw new InkerRenderError("E_INKER_HELPER_THROW", `Helper '${inv.name}' threw: ${message}`, { templatePath, templateName, expression: inv.name }, { cause });
538
- }
539
- if (typeof thenProp === "function") {
540
- throw new InkerRenderError("E_INKER_HELPER_THROW", `Helper '${inv.name}' returned a Promise/thenable — Inker renderers are synchronous (D2)`, { templatePath, templateName, expression: inv.name });
541
- }
542
- if (result instanceof SafeString) {
543
- out.push({ value: result.value, isSafe: true });
544
- }
545
- else if (result === null || result === undefined) {
546
- out.push({ value: "", isSafe: false });
547
- }
548
- else if (typeof result === "string") {
549
- out.push({ value: result, isSafe: false });
550
- }
551
- else {
552
- throw new InkerRenderError("E_INKER_HELPER_THROW", `Helper '${inv.name}' returned ${typeof result} — Inker helpers must return string | SafeString | null | undefined (D2)`, { templatePath, templateName, expression: inv.name });
553
- }
554
- }
555
- return out;
674
+ // No disk directives here (rejected above), so a bare node list renders
675
+ // through the Node renderer (62-2 pivot) with the helpers in scope.
676
+ // Validate the data tree (guard side-effects only; render the original).
677
+ encodeData(data);
678
+ return renderNodeTree(this.#astNodes(ast), data, this.#helpers, {
679
+ tags: this.#tags,
680
+ });
556
681
  }
557
682
  clearCache() {
558
683
  this.#cacheGeneration += 1;
@@ -564,26 +689,35 @@ export class Templates {
564
689
  // #cacheGeneration counter discards that write — see #loadAstUncached.
565
690
  this.#inflight.clear();
566
691
  }
567
- async #loadAst(absPath, validatedName) {
568
- const inflight = this.#inflight.get(absPath);
692
+ async #loadAst(absPath, validatedName, root) {
693
+ // Key the cache/inflight maps by the resolving disk's root AND the
694
+ // absolute path, not the path alone. Two disks with overlapping roots
695
+ // can resolve the SAME absPath; keying by path alone would let disk A's
696
+ // entry (which passed containment against A's root) be served to disk B
697
+ // on a cache hit — bypassing B's symlink-containment check, which only
698
+ // runs on a cache MISS. The compound key isolates each disk's cache.
699
+ const cacheKey = `${root}\u0000${absPath}`;
700
+ const inflight = this.#inflight.get(cacheKey);
569
701
  if (inflight !== undefined)
570
702
  return inflight;
571
- const promise = this.#loadAstUncached(absPath, validatedName);
572
- this.#inflight.set(absPath, promise);
703
+ const promise = this.#loadAstUncached(absPath, validatedName, root);
704
+ this.#inflight.set(cacheKey, promise);
573
705
  try {
574
706
  return await promise;
575
707
  }
576
708
  finally {
577
- this.#inflight.delete(absPath);
709
+ this.#inflight.delete(cacheKey);
578
710
  }
579
711
  }
580
- async #loadAstUncached(absPath, validatedName) {
712
+ async #loadAstUncached(absPath, validatedName, root) {
581
713
  // T7: snapshot the cache generation. If clearCache() runs while this
582
714
  // load is in flight, the snapshot will diverge from #cacheGeneration
583
715
  // at write-back time, and we'll skip the cache.set() to avoid
584
716
  // silently restoring a stale AST after operator invalidation.
585
717
  const loadGeneration = this.#cacheGeneration;
586
- const cached = this.#cache.get(absPath);
718
+ // Same compound (root, absPath) key as #loadAst — see the note there.
719
+ const cacheKey = `${root}\u0000${absPath}`;
720
+ const cached = this.#cache.get(cacheKey);
587
721
  if (this.#cacheMode === "never" && cached !== undefined) {
588
722
  return cached.ast;
589
723
  }
@@ -639,9 +773,9 @@ export class Templates {
639
773
  throw wrapFsError(cause, absPath, validatedName);
640
774
  }
641
775
  if (realPath !== absPath) {
642
- const rel = path.relative(this.#root, realPath);
776
+ const rel = path.relative(root, realPath);
643
777
  if (rel !== "" && (rel.startsWith("..") || path.isAbsolute(rel))) {
644
- throw new InkerRenderError("E_INKER_INVALID_PATH", `Resolved template path escapes the templates root via symlink: ${realPath} is outside ${this.#root}`, { templatePath: realPath, templateName: validatedName });
778
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Resolved template path escapes the templates root via symlink: ${realPath} is outside ${root}`, { templatePath: realPath, templateName: validatedName });
645
779
  }
646
780
  }
647
781
  try {
@@ -657,23 +791,23 @@ export class Templates {
657
791
  // T4: strip leading UTF-8 BOM if present. Windows editors (Notepad)
658
792
  // commonly insert it; lex sees it as a Text token, defeating the
659
793
  // "first non-stripped node must be Layout" composition rule and
660
- // silently treating `{% layout %}` as body content.
794
+ // silently treating `@layout()` as body content.
661
795
  if (source.charCodeAt(0) === 0xfeff) {
662
796
  source = source.slice(1);
663
797
  }
664
- const ast = callNative(() => getNative().parseTemplate(source, [...this.#helperNames]));
798
+ const ast = callNative(() => getNative().parseTemplate(source, [...this.#helperNames], [...this.#tags.keys()]));
665
799
  // T7: only populate the cache if the generation is unchanged. If
666
800
  // clearCache() was called during the await chain above, the new
667
801
  // generation discards this write — the next render() starts fresh.
668
802
  if (this.#cacheGeneration === loadGeneration) {
669
- this.#cache.set(absPath, { ast, mtimeMs: currentMtime });
803
+ this.#cache.set(cacheKey, { ast, mtimeMs: currentMtime });
670
804
  }
671
805
  return ast;
672
806
  }
673
807
  async #compose(entryAst, entryName, entryAbsPath, includeStack) {
674
808
  const partialAsts = new Map();
675
809
  const componentAsts = new Map();
676
- // The Rust parser already separates the leading `{% layout %}` into
810
+ // The Rust parser already separates the leading `@layout()` into
677
811
  // `ast.layout` and excludes it from `ast.nodes`, so `entryAst` IS the
678
812
  // body AST (no slice). Duplicate / mis-placed layout directives are
679
813
  // rejected at parse time (parseTemplate throws E_INKER_DUPLICATE_LAYOUT /
@@ -709,9 +843,7 @@ export class Templates {
709
843
  }
710
844
  const layoutLine = entryInfo.layoutLine ?? undefined;
711
845
  const layoutColumn = entryInfo.layoutColumn ?? undefined;
712
- const layoutValidated = validateName(layoutName);
713
- const layoutAbsPath = path.join(this.#root, `${layoutValidated}.inker`);
714
- assertContained(this.#root, layoutAbsPath, layoutValidated);
846
+ const { root: layoutRoot, validated: layoutValidated, absPath: layoutAbsPath, } = this.#resolveTemplateFile(layoutName);
715
847
  if (includeStack.has(layoutAbsPath)) {
716
848
  throw new InkerRenderError("E_INKER_CIRCULAR_INCLUDE", `Circular include: ${this.#cycleString(includeStack, layoutAbsPath)} (started at ${entryAbsPath})`, {
717
849
  templatePath: layoutAbsPath,
@@ -723,7 +855,7 @@ export class Templates {
723
855
  includeStack.add(layoutAbsPath);
724
856
  let layoutAst;
725
857
  try {
726
- layoutAst = await this.#loadAst(layoutAbsPath, layoutValidated);
858
+ layoutAst = await this.#loadAst(layoutAbsPath, layoutValidated, layoutRoot);
727
859
  }
728
860
  catch (e) {
729
861
  includeStack.delete(layoutAbsPath);
@@ -733,7 +865,7 @@ export class Templates {
733
865
  const layoutInfo = layoutAst.composeInfo;
734
866
  // Nested-layout rejection.
735
867
  if (layoutInfo.hasLayout) {
736
- throw new InkerRenderError("E_INKER_NESTED_LAYOUT_UNSUPPORTED", `Layout file '${layoutValidated}' itself contains {% layout %} — nested layouts are not supported`, {
868
+ throw new InkerRenderError("E_INKER_NESTED_LAYOUT_UNSUPPORTED", `Layout file '${layoutValidated}' itself contains @layout() — nested layouts are not supported`, {
737
869
  templatePath: layoutAbsPath,
738
870
  templateName: layoutValidated,
739
871
  line: layoutInfo.layoutLine ?? undefined,
@@ -777,9 +909,7 @@ export class Templates {
777
909
  }
778
910
  async #resolvePartialsIn(refs, partialAsts, componentAsts, includeStack, hostAbsPath) {
779
911
  for (const node of refs) {
780
- const partialValidated = validateName(node.name);
781
- const partialAbsPath = path.join(this.#root, `${partialValidated}.inker`);
782
- assertContained(this.#root, partialAbsPath, partialValidated);
912
+ const { root: partialRoot, validated: partialValidated, absPath: partialAbsPath, } = this.#resolveTemplateFile(node.name);
783
913
  const partialKey = normalizePartialKey(node.name);
784
914
  if (includeStack.has(partialAbsPath)) {
785
915
  throw new InkerRenderError("E_INKER_CIRCULAR_INCLUDE", `Circular include: ${this.#cycleString(includeStack, partialAbsPath)} (referenced from ${hostAbsPath})`, {
@@ -796,7 +926,7 @@ export class Templates {
796
926
  includeStack.add(partialAbsPath);
797
927
  let partialAst;
798
928
  try {
799
- partialAst = await this.#loadAst(partialAbsPath, partialValidated);
929
+ partialAst = await this.#loadAst(partialAbsPath, partialValidated, partialRoot);
800
930
  }
801
931
  catch (e) {
802
932
  includeStack.delete(partialAbsPath);
@@ -806,7 +936,7 @@ export class Templates {
806
936
  const info = partialAst.composeInfo;
807
937
  // Layout-in-partial rejection.
808
938
  if (info.hasLayout) {
809
- throw new InkerRenderError("E_INKER_LAYOUT_IN_PARTIAL", `Partial file '${partialValidated}' contains {% layout %} — partials cannot declare layouts`, {
939
+ throw new InkerRenderError("E_INKER_LAYOUT_IN_PARTIAL", `Partial file '${partialValidated}' contains @layout() — partials cannot declare layouts`, {
810
940
  templatePath: partialAbsPath,
811
941
  templateName: partialValidated,
812
942
  line: info.layoutLine ?? undefined,
@@ -835,10 +965,10 @@ export class Templates {
835
965
  }
836
966
  async #resolveComponentsIn(refs, partialAsts, componentAsts, includeStack, hostAbsPath) {
837
967
  for (const node of refs) {
838
- const componentName = `components/${node.name}`;
839
- const componentValidated = validateName(componentName);
840
- const componentAbsPath = path.join(this.#root, `${componentValidated}.inker`);
841
- assertContained(this.#root, componentAbsPath, componentValidated);
968
+ // Split the optional `disk::` prefix off FIRST, then prepend the
969
+ // `components/` directory to the bare name so `disk::button` resolves
970
+ // to `<disk>/components/button.inker`, not `<default>/components/disk::button`.
971
+ const { root: componentRoot, validated: componentValidated, absPath: componentAbsPath, } = this.#resolveTemplateFile(node.name, "components/");
842
972
  const componentKey = normalizePartialKey(node.name);
843
973
  if (includeStack.has(componentAbsPath)) {
844
974
  throw new InkerRenderError("E_INKER_CIRCULAR_INCLUDE", `Circular include: ${this.#cycleString(includeStack, componentAbsPath)} (referenced from ${hostAbsPath})`, {
@@ -854,7 +984,7 @@ export class Templates {
854
984
  includeStack.add(componentAbsPath);
855
985
  let componentAst;
856
986
  try {
857
- componentAst = await this.#loadAst(componentAbsPath, componentValidated);
987
+ componentAst = await this.#loadAst(componentAbsPath, componentValidated, componentRoot);
858
988
  }
859
989
  catch (e) {
860
990
  includeStack.delete(componentAbsPath);
@@ -865,26 +995,20 @@ export class Templates {
865
995
  // Layout-in-component rejection (reuse E_INKER_LAYOUT_IN_PARTIAL
866
996
  // per AC5: same axis "layout in non-entry file").
867
997
  if (info.hasLayout) {
868
- throw new InkerRenderError("E_INKER_LAYOUT_IN_PARTIAL", `Component file '${componentValidated}' contains {% layout %} — components cannot declare layouts`, {
998
+ throw new InkerRenderError("E_INKER_LAYOUT_IN_PARTIAL", `Component file '${componentValidated}' contains @layout() — components cannot declare layouts`, {
869
999
  templatePath: componentAbsPath,
870
1000
  templateName: componentValidated,
871
1001
  line: info.layoutLine ?? undefined,
872
1002
  column: info.layoutColumn ?? undefined,
873
1003
  });
874
1004
  }
875
- // Slot-leak rejection: components MUST NOT contain {{> body }}.
876
- const slot = info.slots[0];
877
- if (slot !== undefined) {
878
- throw new InkerRenderError("E_INKER_UNKNOWN_SLOT", `Component '${componentValidated}' contains {{> ${slot.name} }} slot placeholders are only valid inside layout files (line ${slot.line}, column ${slot.column})`, {
879
- templatePath: componentAbsPath,
880
- templateName: componentValidated,
881
- line: slot.line,
882
- column: slot.column,
883
- });
884
- }
1005
+ // A component template MAY contain slot placeholders: `{{> body }}`
1006
+ // yields the default (block-body) slot and `{{> name }}` a named
1007
+ // `@slot('name')` provided by the caller. Placeholders with no
1008
+ // matching slot render empty (Edge parity), so no validation here.
885
1009
  componentAsts.set(componentKey, componentAst);
886
1010
  // Recurse into nested components AND partials included inside this
887
- // component (mutual recursion → a {% include %} in a component is
1011
+ // component (mutual recursion → a @include() in a component is
888
1012
  // pre-loaded, fixing E_INKER_DISK_REQUIRED at render time).
889
1013
  await this.#resolveComponentsIn(info.components, partialAsts, componentAsts, includeStack, componentAbsPath);
890
1014
  await this.#resolvePartialsIn(info.partials, partialAsts, componentAsts, includeStack, componentAbsPath);