@c9up/inker 0.1.8 → 0.1.10

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 (42) hide show
  1. package/dist/InkerProvider.d.ts +23 -1
  2. package/dist/InkerProvider.d.ts.map +1 -1
  3. package/dist/InkerProvider.js +133 -2
  4. package/dist/InkerProvider.js.map +1 -1
  5. package/dist/InkerRenderError.d.ts +8 -1
  6. package/dist/InkerRenderError.d.ts.map +1 -1
  7. package/dist/InkerRenderError.js.map +1 -1
  8. package/dist/Templates.d.ts +235 -4
  9. package/dist/Templates.d.ts.map +1 -1
  10. package/dist/Templates.js +832 -91
  11. package/dist/Templates.js.map +1 -1
  12. package/dist/globals.d.ts +3 -3
  13. package/dist/globals.d.ts.map +1 -1
  14. package/dist/globals.js +190 -6
  15. package/dist/globals.js.map +1 -1
  16. package/dist/helpers.d.ts +11 -1
  17. package/dist/helpers.d.ts.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +5 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/loadNapi.d.ts +6 -2
  23. package/dist/loadNapi.d.ts.map +1 -1
  24. package/dist/loadNapi.js.map +1 -1
  25. package/dist/renderNode.d.ts +99 -3
  26. package/dist/renderNode.d.ts.map +1 -1
  27. package/dist/renderNode.js +375 -57
  28. package/dist/renderNode.js.map +1 -1
  29. package/dist/stacks.d.ts +25 -0
  30. package/dist/stacks.d.ts.map +1 -0
  31. package/dist/stacks.js +97 -0
  32. package/dist/stacks.js.map +1 -0
  33. package/dist/testing/index.d.ts +42 -0
  34. package/dist/testing/index.d.ts.map +1 -0
  35. package/dist/testing/index.js +50 -0
  36. package/dist/testing/index.js.map +1 -0
  37. package/index.darwin-arm64.node +0 -0
  38. package/index.darwin-x64.node +0 -0
  39. package/index.linux-arm64-gnu.node +0 -0
  40. package/index.linux-x64-gnu.node +0 -0
  41. package/index.win32-x64-msvc.node +0 -0
  42. package/package.json +8 -4
package/dist/Templates.js CHANGED
@@ -1,11 +1,13 @@
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
+ import { fileURLToPath } from "node:url";
5
+ import { INKER_GLOBAL_NAMES } from "./globals.js";
5
6
  import { InkerRenderError } from "./InkerRenderError.js";
6
7
  import { PROTOTYPE_POLLUTION_KEYS, RESERVED_BINDING_NAMES, } from "./identifierGuards.js";
7
8
  import { getNative, napiThrowToInker, } from "./loadNapi.js";
8
- import { collectSections, renderNodeTree, } from "./renderNode.js";
9
+ import { collectSections, renderNodeTree, renderNodeTreeAsync, } from "./renderNode.js";
10
+ import { Stacks } from "./stacks.js";
9
11
  const HELPER_NAME_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
10
12
  // P13 — Windows-reserved device basenames. Refused on every platform for
11
13
  // portability: a template named `con.inker` would resolve to the Windows
@@ -34,11 +36,36 @@ const WINDOWS_RESERVED = new Set([
34
36
  "lpt8",
35
37
  "lpt9",
36
38
  ]);
39
+ /** The template file extension, shared by resolution and the component scan. */
40
+ const TEMPLATE_EXT = ".inker";
41
+ /** A component tag name: dot-separated camelCase segments (`form.input`). */
42
+ const COMPONENT_TAG_RE = /^[a-z][A-Za-z0-9]*(\.[a-z][A-Za-z0-9]*)*$/;
43
+ /** camelCase one path segment for a component tag (`my-button` → `myButton`). */
44
+ function camelCaseSegment(segment) {
45
+ return segment
46
+ .split(/[-_\s]+/)
47
+ .filter((w) => w.length > 0)
48
+ .map((w, i) => i === 0
49
+ ? w.charAt(0).toLowerCase() + w.slice(1)
50
+ : w.charAt(0).toUpperCase() + w.slice(1))
51
+ .join("");
52
+ }
37
53
  const VALID_CACHE_MODES = new Set([
38
54
  "auto",
39
55
  "mtime",
40
56
  "never",
41
57
  ]);
58
+ /** Validate a requested cache mode and resolve `auto` against the environment.
59
+ * Shared by the constructor and `configure()` so both apply the same rules. */
60
+ function resolveCacheMode(requested) {
61
+ if (!VALID_CACHE_MODES.has(requested)) {
62
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates cacheMode must be one of 'auto' | 'mtime' | 'never'; got ${JSON.stringify(requested)}`);
63
+ }
64
+ if (requested === "auto") {
65
+ return process.env.NODE_ENV === "production" ? "never" : "mtime";
66
+ }
67
+ return requested;
68
+ }
42
69
  // Built-in block/directive keywords a custom tag may not shadow (registerTag).
43
70
  // The parser already ignores these as custom tags; rejecting them here makes the
44
71
  // collision loud instead of silently inert.
@@ -409,7 +436,7 @@ function validateHelpers(rawHelpers) {
409
436
  if (!(helpers instanceof Map)) {
410
437
  throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates.helpers must be a Map; got ${Object.prototype.toString.call(helpers).slice(8, -1)}`);
411
438
  }
412
- const helperNames = new Set(EDGE_GLOBAL_NAMES);
439
+ const helperNames = new Set(INKER_GLOBAL_NAMES);
413
440
  for (const [key, value] of helpers) {
414
441
  // T3: validate the key is a string BEFORE handing it to
415
442
  // HELPER_NAME_RE.test(), which ToString-coerces and throws a raw
@@ -438,9 +465,126 @@ function validateHelpers(rawHelpers) {
438
465
  }
439
466
  return { helpers, helperNames };
440
467
  }
468
+ /**
469
+ * Source and output transforms (Edge `edge.processor`).
470
+ *
471
+ * INKER DEVIATION (named): Edge also exposes a `compiled` stage that rewrites
472
+ * the JavaScript its compiler emits. Inker has no such stage — templates parse
473
+ * to an AST in Rust and render by walking it, so there is no intermediate code
474
+ * to rewrite. Registering `compiled` throws rather than sitting silently inert.
475
+ */
476
+ export class Processor {
477
+ #raw = [];
478
+ #output = [];
479
+ #onRawRegistered;
480
+ constructor(onRawRegistered) {
481
+ this.#onRawRegistered = onRawRegistered;
482
+ }
483
+ // The overloads above are what callers see, and they give the callback its
484
+ // parameter type. The implementation signature takes the INTERSECTION of the
485
+ // two handler types: a value of that type is assignable to either registry,
486
+ // so the dispatch below needs no cast.
487
+ process(stage, fn) {
488
+ if (typeof fn !== "function") {
489
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `processor.process('${String(stage)}') — handler must be a function; got ${typeof fn}`);
490
+ }
491
+ if (stage === "raw") {
492
+ this.#raw.push(fn);
493
+ this.#onRawRegistered();
494
+ return;
495
+ }
496
+ if (stage === "output") {
497
+ this.#output.push(fn);
498
+ return;
499
+ }
500
+ // Unreachable from TypeScript; reachable from plain JS, and a stage that
501
+ // never fires would be a silent no-op — so it is loud instead.
502
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `processor.process — unknown stage '${String(stage)}'. Inker supports 'raw' and 'output'. Edge's 'compiled' stage has no equivalent: no JavaScript is emitted, templates parse to an AST. Its 'tag' stage has none either: parsing happens in Rust, so a JS handler cannot mutate a token mid-parse — and its canonical use, exposing components as tags, is built in (see listComponents).`);
503
+ }
504
+ /** Apply every `raw` transform in registration order. */
505
+ applyRaw(raw, path) {
506
+ let out = raw;
507
+ // A processor returning undefined leaves the value untouched, so a
508
+ // transform can bail out without reconstructing its input.
509
+ for (const fn of this.#raw) {
510
+ const next = fn({ raw: out, path });
511
+ if (typeof next === "string")
512
+ out = next;
513
+ }
514
+ return out;
515
+ }
516
+ /** Apply every `output` transform in registration order. */
517
+ applyOutput(output, template) {
518
+ let out = output;
519
+ for (const fn of this.#output) {
520
+ const next = fn({ output: out, template });
521
+ if (typeof next === "string")
522
+ out = next;
523
+ }
524
+ return out;
525
+ }
526
+ }
527
+ /**
528
+ * After the handle is open, confirm the file the OS actually resolved is still
529
+ * inside `root`. `O_NOFOLLOW` already refused a symlinked final segment; this
530
+ * catches an intermediate directory that is one.
531
+ *
532
+ * Extracted so the synchronous loader runs the SAME check — a second copy of a
533
+ * containment rule is how the two paths drift apart.
534
+ */
535
+ function assertRealpathContained(absPath, root, validatedName) {
536
+ let realPath;
537
+ try {
538
+ // `.native` to match validateRoot's canonical form (same OS realpath)
539
+ // so 8.3-short-name / casing differences don't trip containment.
540
+ realPath = fs.realpathSync.native(absPath);
541
+ }
542
+ catch (cause) {
543
+ throw wrapFsError(cause, absPath, validatedName);
544
+ }
545
+ if (realPath === absPath)
546
+ return;
547
+ const rel = path.relative(root, realPath);
548
+ if (rel !== "" && (rel.startsWith("..") || path.isAbsolute(rel))) {
549
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Resolved template path escapes the templates root via symlink: ${realPath} is outside ${root}`, { templatePath: realPath, templateName: validatedName });
550
+ }
551
+ }
552
+ function askedForAst(answer) {
553
+ if (typeof answer === "string") {
554
+ throw new InkerRenderError("E_INKER_INVALID_PATH", "render driver answered a template load with rendered HTML");
555
+ }
556
+ return answer;
557
+ }
558
+ function askedForHtml(answer) {
559
+ if (typeof answer !== "string") {
560
+ throw new InkerRenderError("E_INKER_INVALID_PATH", "render driver answered a sub-render with a template AST");
561
+ }
562
+ return answer;
563
+ }
564
+ /** Shorthand so a request reads like the call it replaced. */
565
+ function loadRequest(absPath, validatedName, root) {
566
+ return { absPath, validatedName, root };
567
+ }
568
+ /** A callable global doubles as a helper, so the parser accepts `{{ name(x) }}`
569
+ * as a call rather than an unknown-helper error. `typeof` narrows to `Function`,
570
+ * which carries no signature — this guard is what states the contract. */
571
+ function isHelperFn(value) {
572
+ return typeof value === "function";
573
+ }
441
574
  export class Templates {
442
575
  #root;
576
+ // Not readonly: `configure()` can swap it on a live engine (Edge parity).
443
577
  #cacheMode;
578
+ /** Callbacks registered through `onRender`, run on every `createRenderer`. */
579
+ #renderCallbacks = [];
580
+ /** Components exposed as tags, refreshed before each render. `undefined`
581
+ * until the first scan. */
582
+ #componentTags;
583
+ /** Plugins registered through `use`, run lazily at the first render. */
584
+ // Each entry stores an already-bound CALL rather than the raw function: the
585
+ // plugin's option type is generic per registration, and a list of raw
586
+ // `InkerPluginFn<T>` cannot be typed without widening `T` unsoundly.
587
+ #plugins = [];
444
588
  #cache = new Map();
445
589
  #inflight = new Map();
446
590
  // T7: monotonic counter bumped by clearCache(). #loadAstUncached snapshots
@@ -449,6 +593,36 @@ export class Templates {
449
593
  // from silently re-populating the cache after clearCache() ran.
450
594
  #cacheGeneration = 0;
451
595
  #helpers;
596
+ // Values shared with EVERY template (Edge `edge.global`). Kept apart from
597
+ // `#helpers`: a helper name is handed to the Rust lexer at parse time so
598
+ // `@name()` resolves as a call, whereas a global is plain render state and
599
+ // must NOT change how a template parses.
600
+ #globals = new Map();
601
+ // Globals whose value is callable. The Rust parser validates `{{ name(…) }}`
602
+ // against the helper-name list handed to `parseTemplate`, so a callable global
603
+ // must ALSO be published as a helper or the template fails to parse with
604
+ // E_INKER_UNKNOWN_HELPER. Kept separate from `#helpers` (constructor-supplied,
605
+ // frozen) so the composed view can be memoised and invalidated on its own.
606
+ #globalFns = new Map();
607
+ #composedHelpers;
608
+ // Source/output transforms (Edge `edge.processor.process`). INKER DEVIATION
609
+ // (named): Edge also exposes a `compiled` stage that rewrites the JavaScript
610
+ // its compiler emits. Inker has no such stage — templates parse to an AST in
611
+ // Rust and render by walking it, so there is no intermediate code to rewrite.
612
+ // Registering `compiled` therefore throws rather than silently never firing.
613
+ // Templates registered from memory (Edge `registerTemplate`). Keyed by the
614
+ // SAME validated name a disk lookup would produce, so `@include('x')` and
615
+ // `@component('components/x')` resolve here before any filesystem access —
616
+ // an in-memory template has no path, so the containment and symlink guards
617
+ // simply never come into play for it.
618
+ #inMemory = new Map();
619
+ /**
620
+ * Source and output transforms (Edge `edge.processor`). A `raw` transform
621
+ * changes what gets parsed, so registering one clears the AST cache.
622
+ */
623
+ processor = new Processor(() => {
624
+ this.clearCache();
625
+ });
452
626
  #helperNames;
453
627
  // Runtime-registered custom tags (Edge `registerTag`). Names here make the
454
628
  // parser recognise `@<tagName>(jsArg)` as a `CustomTag` node; the tag's
@@ -462,19 +636,13 @@ export class Templates {
462
636
  // #loadAst symlink guard) is enforced against the disk's OWN root, never a
463
637
  // shared one, so mounting a package's templates cannot widen traversal.
464
638
  #disks = new Map();
639
+ /** Construct an engine (Edge `Edge.create`). Mirrors `new Templates(...)`. */
640
+ static create(options) {
641
+ return new Templates(options);
642
+ }
465
643
  constructor(options) {
466
644
  this.#root = canonicalizeTemplatesRoot(options.root);
467
- const requested = options.cacheMode ?? "auto";
468
- if (!VALID_CACHE_MODES.has(requested)) {
469
- throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates cacheMode must be one of 'auto' | 'mtime' | 'never'; got ${JSON.stringify(requested)}`);
470
- }
471
- if (requested === "auto") {
472
- this.#cacheMode =
473
- process.env.NODE_ENV === "production" ? "never" : "mtime";
474
- }
475
- else {
476
- this.#cacheMode = requested;
477
- }
645
+ this.#cacheMode = resolveCacheMode(options.cacheMode ?? "auto");
478
646
  // P14 reverted: `Templates#helpers` is intentionally a LIVE reference,
479
647
  // documented by the `resolves helper implementation LIVE per call (D4)`
480
648
  // regression test. Parse-time validation is fixed to the helper SET
@@ -484,26 +652,15 @@ export class Templates {
484
652
  this.#helpers = helpers;
485
653
  this.#helperNames = helperNames;
486
654
  }
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) {
655
+ mount(diskNameOrDir, maybeDir) {
656
+ // Edge's one-argument form mounts the default disk, which is what
657
+ // `edge.mount(new URL('./views', import.meta.url))` relies on.
658
+ const diskName = maybeDir === undefined ? "default" : String(diskNameOrDir);
659
+ const dir = maybeDir ?? diskNameOrDir;
505
660
  assertDiskName(diskName);
506
- const root = canonicalizeTemplatesRoot(dir);
661
+ // Edge mounts with `new URL('./views', import.meta.url)`; accept that form
662
+ // so a directory computed from a module's own location ports unchanged.
663
+ const root = canonicalizeTemplatesRoot(dir instanceof URL ? fileURLToPath(dir) : dir);
507
664
  const existing = this.#disks.get(diskName);
508
665
  if (existing !== undefined && existing !== root) {
509
666
  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.`);
@@ -529,9 +686,15 @@ export class Templates {
529
686
  * evaluate a template expression) and whose `token.properties.jsArg` is the
530
687
  * verbatim argument source, e.g. an `@svg('icon')` or `@time()` tag.
531
688
  *
689
+ * A tag declared `block: true` takes a body closed by `@end<tagName>` (or is
690
+ * self-closed as `@!<tagName>`); its `compile` reads the body through
691
+ * `token.renderBody()`.
692
+ *
532
693
  * INKER DEVIATION (named): Edge runs `compile` once at compilation (it emits
533
694
  * 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.
695
+ * runs at RENDER time and a block tag therefore receives its body already
696
+ * rendered (`token.renderBody()`) rather than Edge's raw `token.children`
697
+ * lexer tokens, which have no counterpart here.
535
698
  *
536
699
  * Because tag names change how a template PARSES, registering (or overwriting)
537
700
  * a tag clears the AST cache — call `registerTag` during boot, before rendering.
@@ -547,13 +710,146 @@ export class Templates {
547
710
  if (typeof tag.compile !== "function") {
548
711
  throw new InkerRenderError("E_INKER_INVALID_PATH", `registerTag({ tagName: '${name}' }) — compile must be a function`);
549
712
  }
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
713
  this.#tags.set(name, tag);
554
714
  // A new tag name changes parse output; drop any AST parsed without it.
555
715
  this.clearCache();
556
716
  }
717
+ /**
718
+ * Absolute path a template name resolves to (Edge `loader.makePath`), disk
719
+ * prefix and containment checks included. Does not touch the filesystem —
720
+ * use it to report WHERE a template was looked for.
721
+ */
722
+ makePath(name) {
723
+ return this.#resolveTemplateFile(name).absPath;
724
+ }
725
+ /**
726
+ * A template's source (Edge `loader.resolve`). An in-memory template
727
+ * registered through `registerTemplate` wins over the disk, exactly as it
728
+ * does during a render.
729
+ *
730
+ * INKER DEVIATION (named): Edge hangs this on `edge.loader`; inker has no
731
+ * separate loader object, so the disk surface lives on the engine itself.
732
+ */
733
+ resolve(name) {
734
+ const inMemory = this.#inMemory.get(validateName(this.#splitDisk(name).bare));
735
+ if (inMemory !== undefined)
736
+ return { template: inMemory };
737
+ const { absPath } = this.#resolveTemplateFile(name);
738
+ try {
739
+ return { template: fs.readFileSync(absPath, "utf8") };
740
+ }
741
+ catch {
742
+ throw new InkerRenderError("E_INKER_TEMPLATE_NOT_FOUND", `Template not found: ${absPath}`, { templateName: name, templatePath: absPath });
743
+ }
744
+ }
745
+ /** The mounted disks, name → canonicalised root (Edge `loader.mounted`).
746
+ * `default` is the root the engine was constructed with. */
747
+ get mounted() {
748
+ const out = { default: this.#root };
749
+ for (const [name, root] of this.#disks)
750
+ out[name] = root;
751
+ return Object.freeze(out);
752
+ }
753
+ /** Templates registered in memory (Edge `loader.templates`). */
754
+ get templates() {
755
+ const out = Object.create(null);
756
+ for (const [name, template] of this.#inMemory)
757
+ out[name] = { template };
758
+ return Object.freeze(out);
759
+ }
760
+ /**
761
+ * Every component reachable as a tag, per mounted disk (Edge
762
+ * `loader.listComponents`). A `components/button.inker` becomes `@button`,
763
+ * `components/form/input.inker` becomes `@form.input`, and an `index`
764
+ * segment drops out so `components/form/index.inker` is `@form`. Names are
765
+ * camel-cased, and a non-default disk prefixes its own name.
766
+ */
767
+ listComponents() {
768
+ const disks = [["default", this.#root]];
769
+ for (const [name, root] of this.#disks)
770
+ disks.push([name, root]);
771
+ return disks.map(([diskName, root]) => ({
772
+ diskName,
773
+ components: this.#scanComponents(diskName, root),
774
+ }));
775
+ }
776
+ #scanComponents(diskName, root) {
777
+ const dir = path.join(root, "components");
778
+ let files;
779
+ try {
780
+ files = fs
781
+ .readdirSync(dir, { recursive: true, encoding: "utf8" })
782
+ .filter((f) => f.endsWith(TEMPLATE_EXT));
783
+ }
784
+ catch {
785
+ // No components directory on this disk — not an error.
786
+ return [];
787
+ }
788
+ const out = [];
789
+ for (const file of files) {
790
+ const rel = file.slice(0, -TEMPLATE_EXT.length).split(path.sep).join("/");
791
+ const segments = rel.split("/");
792
+ const tag = segments
793
+ // A trailing `index` names its directory: `form/index` → `form`.
794
+ .filter((seg, i) => i === 0 || seg !== "index")
795
+ .map((seg) => camelCaseSegment(seg))
796
+ .join(".");
797
+ if (tag === "" || !COMPONENT_TAG_RE.test(tag))
798
+ continue;
799
+ // INKER DEVIATION (named): Edge's `componentName` is the full
800
+ // `components/<path>`; inker's `@component()` already resolves under
801
+ // `components/`, so the name a caller can actually pass is the bare
802
+ // relative path.
803
+ const componentName = rel;
804
+ out.push(diskName === "default"
805
+ ? { componentName, tagName: tag }
806
+ : {
807
+ componentName: `${diskName}::${componentName}`,
808
+ tagName: `${diskName}.${tag}`,
809
+ });
810
+ }
811
+ return out;
812
+ }
813
+ /**
814
+ * Refresh the component-tag map. Called before every render (like Edge's
815
+ * bundled `supercharged` plugin), but the directory is only re-scanned when
816
+ * AST caching is off — with caching on, a new component file needs a
817
+ * `clearCache()` anyway.
818
+ */
819
+ #refreshComponentTags() {
820
+ if (this.#componentTags !== undefined && this.#cacheMode !== "mtime")
821
+ return;
822
+ const next = new Map();
823
+ for (const { components } of this.listComponents()) {
824
+ for (const { componentName, tagName } of components) {
825
+ next.set(tagName, componentName);
826
+ }
827
+ }
828
+ const changed = this.#componentTags === undefined ||
829
+ this.#componentTags.size !== next.size ||
830
+ [...next].some(([k, v]) => this.#componentTags?.get(k) !== v);
831
+ this.#componentTags = next;
832
+ // The map changes how templates PARSE, so a stale AST must not survive it.
833
+ if (changed && this.#componentTags !== undefined)
834
+ this.clearCache();
835
+ }
836
+ /** The component-tag map as the JSON the Rust parser expects. */
837
+ #componentTagsJson() {
838
+ if (this.#componentTags === undefined || this.#componentTags.size === 0) {
839
+ return "";
840
+ }
841
+ return JSON.stringify(Object.fromEntries(this.#componentTags));
842
+ }
843
+ /** Names of the registered tags declared `block: true` — the parser needs
844
+ * them separately, to know which `@end<name>` closers exist. */
845
+ #blockTagNames() {
846
+ const names = [];
847
+ for (const [name, tag] of this.#tags) {
848
+ if (tag.block === true)
849
+ names.push(name);
850
+ }
851
+ return names;
852
+ }
557
853
  /**
558
854
  * Split an optionally-namespaced template name into its resolution root and
559
855
  * bare (disk-relative) name. `name::path` → the mounted disk's root; a bare
@@ -580,19 +876,66 @@ export class Templates {
580
876
  #resolveTemplateFile(name, prefix = "") {
581
877
  const { root, bare } = this.#splitDisk(name);
582
878
  const validated = validateName(`${prefix}${bare}`);
583
- const absPath = path.join(root, `${validated}.inker`);
879
+ const absPath = path.join(root, `${validated}${TEMPLATE_EXT}`);
584
880
  assertContained(root, absPath, validated);
585
881
  return { root, validated, absPath };
586
882
  }
587
- async render(name, data) {
883
+ /**
884
+ * Render a template from disk.
885
+ *
886
+ * The whole body — resolution, composition, layout/section assembly — is a
887
+ * generator that ASKS for each load and each sub-render. `render` serves
888
+ * those requests with promise I/O, `renderSync` with the synchronous calls,
889
+ * and neither owns a second copy of the logic. That is what makes a
890
+ * `renderSync` safe to offer at all: the containment rules and the
891
+ * composition run from ONE place.
892
+ */
893
+ async render(name, data = {}) {
894
+ const step = this.#renderSteps(name, data);
895
+ let next = step.next();
896
+ while (!next.done) {
897
+ const req = next.value;
898
+ const html = "nodes" in req
899
+ ? await renderNodeTreeAsync(req.nodes, req.state, this.#renderHelpers(), req.ctx)
900
+ : await this.#loadAst(req.absPath, req.validatedName, req.root);
901
+ next = step.next(html);
902
+ }
903
+ return next.value;
904
+ }
905
+ /**
906
+ * Render a template from disk, synchronously (AdonisJS `renderSync`).
907
+ *
908
+ * An expression using `await` raises here, exactly as it does upstream —
909
+ * `render` is the awaiting counterpart.
910
+ */
911
+ renderSync(name, data = {}) {
912
+ const step = this.#renderSteps(name, data);
913
+ let next = step.next();
914
+ while (!next.done) {
915
+ const req = next.value;
916
+ const html = "nodes" in req
917
+ ? renderNodeTree(req.nodes, req.state, this.#renderHelpers(), req.ctx)
918
+ : this.#loadAstSync(req.absPath, req.validatedName, req.root);
919
+ next = step.next(html);
920
+ }
921
+ return next.value;
922
+ }
923
+ *#renderSteps(name, data) {
924
+ // Plugins run before anything is resolved — one may register a global or
925
+ // a tag this very render depends on.
926
+ this.#executePlugins();
588
927
  const { root, validated, absPath } = this.#resolveTemplateFile(name);
928
+ // Registered globals sit UNDER the caller's data; validation then runs on
929
+ // the merged tree, so a bad global is rejected here rather than surfacing
930
+ // as a render-time fault in an unrelated template.
931
+ const state = this.#withGlobals(data);
589
932
  // Validate the data tree (rejects NaN / ±Infinity / out-of-range bigint /
590
933
  // sparse holes / circular refs) — the Node renderer evaluates the ORIGINAL
591
934
  // data in V8 (Maps/Sets intact), so `encodeData`'s result is discarded and
592
935
  // only its guard side-effects are kept.
593
- encodeData(data);
594
- const entryAst = await this.#loadAst(absPath, validated, root);
595
- const composed = await this.#compose(entryAst, validated, absPath, new Set([absPath]));
936
+ encodeData(state);
937
+ const entryAst = askedForAst(yield loadRequest(absPath, validated, root));
938
+ const composed = yield* this.#compose(entryAst, validated, absPath, new Set([absPath]));
596
939
  // Node renderer (62-2 pivot): convert the loaded AST handles to JSON node
597
940
  // lists and evaluate every expression in Node's own V8 with the helpers in
598
941
  // scope (Edge model — no tape, no QuickJS, no FFI).
@@ -605,41 +948,53 @@ export class Templates {
605
948
  components.set(key, this.#astNodes(handle));
606
949
  }
607
950
  const childNodes = this.#astNodes(composed.bodyAst);
608
- const baseCtx = { partials, components, tags: this.#tags };
951
+ // ONE stack store for the whole composition: `@pushTo` in the body (or in
952
+ // a partial, or a component) must reach a `@stack` the layout renders
953
+ // last. Placeholders are substituted once everything has rendered.
954
+ const stacks = new Stacks();
955
+ const baseCtx = {
956
+ partials,
957
+ components,
958
+ tags: this.#tags,
959
+ templateName: name,
960
+ stacks,
961
+ };
609
962
  // No layout → render the child directly (`@section`s render inline).
610
963
  if (composed.layoutAst === undefined) {
611
- return renderNodeTree(childNodes, data, this.#helpers, baseCtx);
964
+ return this.#applyOutput(stacks.fillPlaceholders(askedForHtml(yield { nodes: childNodes, state, ctx: baseCtx })), name);
612
965
  }
613
966
  // With a layout: separate the child's `@section` fills from the default
614
967
  // body, render each section (with `@super` = the layout's default for it),
615
968
  // and inject them at the layout's matching yields (62-3).
616
969
  const { sections: childSections, body: childBody } = collectSections(childNodes);
617
- const bodyHtml = renderNodeTree(childBody, data, this.#helpers, baseCtx);
970
+ const bodyHtml = askedForHtml(yield { nodes: childBody, state, ctx: baseCtx });
618
971
  const layoutNodes = this.#astNodes(composed.layoutAst);
619
972
  const { sections: layoutDefaults } = collectSections(layoutNodes);
620
973
  const sections = new Map();
621
974
  for (const [name, sectionNodes] of childSections) {
622
975
  const layoutDefault = layoutDefaults.get(name);
623
976
  const superHtml = layoutDefault !== undefined
624
- ? renderNodeTree(layoutDefault, data, this.#helpers, baseCtx)
977
+ ? askedForHtml(yield { nodes: layoutDefault, state, ctx: baseCtx })
625
978
  : "";
626
- sections.set(name, renderNodeTree(sectionNodes, data, this.#helpers, {
627
- ...baseCtx,
628
- superHtml,
979
+ sections.set(name, askedForHtml(yield {
980
+ nodes: sectionNodes,
981
+ state,
982
+ ctx: { ...baseCtx, superHtml },
629
983
  }));
630
984
  }
631
- return renderNodeTree(layoutNodes, data, this.#helpers, {
632
- ...baseCtx,
633
- bodyHtml,
634
- sections,
635
- });
985
+ return this.#applyOutput(stacks.fillPlaceholders(askedForHtml(yield {
986
+ nodes: layoutNodes,
987
+ state,
988
+ ctx: { ...baseCtx, bodyHtml, sections },
989
+ })), name);
636
990
  }
637
991
  /** Convert a parsed AST handle to its JSON node list (62-2 Node renderer). */
638
992
  #astNodes(handle) {
639
993
  const parsed = JSON.parse(getNative().astToJson(handle));
640
994
  return parsed.nodes;
641
995
  }
642
- renderString(source, data) {
996
+ renderString(source, data = {}) {
997
+ this.#executePlugins();
643
998
  // T4 + P15: strip ALL U+FEFF (BOM) characters, not just a leading one.
644
999
  // `validateName` already refuses BOM in any position of a template
645
1000
  // name; source built by concatenating multiple BOM-prefixed fragments
@@ -650,7 +1005,10 @@ export class Templates {
650
1005
  ? source.replace(//g, "")
651
1006
  : source;
652
1007
  const native = getNative();
653
- const ast = callNative(() => native.parseTemplate(normalisedSource, [...this.#helperNames], [...this.#tags.keys()]));
1008
+ // `raw` processors also apply to inline sources — the stage is about the
1009
+ // source text, not about where it came from.
1010
+ const processedSource = this.#applyRaw(normalisedSource);
1011
+ const ast = callNative(() => native.parseTemplate(processedSource, this.#parseNames(), [...this.#tags.keys()], this.#blockTagNames(), this.#componentTagsJson()));
654
1012
  const info = ast.composeInfo;
655
1013
  // The Rust parser separates a leading `@layout()` into `ast.layout`
656
1014
  // (not a body node), so `firstDiskNode` won't surface it — check
@@ -673,11 +1031,257 @@ export class Templates {
673
1031
  }
674
1032
  // No disk directives here (rejected above), so a bare node list renders
675
1033
  // through the Node renderer (62-2 pivot) with the helpers in scope.
1034
+ // Globals apply to inline sources too, on the same precedence as render().
1035
+ const state = this.#withGlobals(data);
676
1036
  // Validate the data tree (guard side-effects only; render the original).
677
- encodeData(data);
678
- return renderNodeTree(this.#astNodes(ast), data, this.#helpers, {
1037
+ encodeData(state);
1038
+ const stacks = new Stacks();
1039
+ return this.#applyOutput(stacks.fillPlaceholders(renderNodeTree(this.#astNodes(ast), state, this.#renderHelpers(), {
679
1040
  tags: this.#tags,
1041
+ stacks,
1042
+ })));
1043
+ }
1044
+ /**
1045
+ * Share a value with every template rendered by this engine (Edge
1046
+ * `edge.global`). Later registrations overwrite earlier ones, and per-render
1047
+ * data always wins over a global of the same name.
1048
+ */
1049
+ global(name, value) {
1050
+ if (typeof name !== "string") {
1051
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Global name must be a string; got ${typeof name}`);
1052
+ }
1053
+ if (!HELPER_NAME_RE.test(name)) {
1054
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Global name '${name}' is not a valid identifier (must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/)`, { templateName: name });
1055
+ }
1056
+ // Same denylist as object-literal keys and each-bindings: a global named
1057
+ // `__proto__` / `constructor` / `prototype` would be assigned onto the
1058
+ // merged state object and shadow Object.prototype for every template.
1059
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) {
1060
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Global name '${name}' is not allowed (prototype-pollution key)`, { templateName: name });
1061
+ }
1062
+ this.#globals.set(name, value);
1063
+ if (isHelperFn(value)) {
1064
+ this.#globalFns.set(name, value);
1065
+ this.#composedHelpers = undefined;
1066
+ // A callable global changes how templates PARSE — `{{ t('k') }}` only
1067
+ // compiles once the parser knows `t`. Same contract as registerTag:
1068
+ // register during boot, before the first render.
1069
+ this.clearCache();
1070
+ }
1071
+ else if (this.#globalFns.delete(name)) {
1072
+ // Overwriting a callable global with a plain value withdraws the helper.
1073
+ this.#composedHelpers = undefined;
1074
+ this.clearCache();
1075
+ }
1076
+ }
1077
+ /** Helper names known to the parser: constructor helpers + callable globals. */
1078
+ #parseNames() {
1079
+ return this.#globalFns.size === 0
1080
+ ? [...this.#helperNames]
1081
+ : [...this.#helperNames, ...this.#globalFns.keys()];
1082
+ }
1083
+ /** Render-time helper map: constructor helpers overlaid with callable globals. */
1084
+ #renderHelpers() {
1085
+ if (this.#globalFns.size === 0)
1086
+ return this.#helpers;
1087
+ if (this.#composedHelpers === undefined) {
1088
+ const merged = new Map(this.#helpers);
1089
+ for (const [name, fn] of this.#globalFns)
1090
+ merged.set(name, fn);
1091
+ this.#composedHelpers = merged;
1092
+ }
1093
+ return this.#composedHelpers;
1094
+ }
1095
+ /**
1096
+ * Run a plugin against this engine (Edge `edge.use`). The plugin receives the
1097
+ * engine and registers whatever it needs — globals, tags. Returns the engine
1098
+ * so calls chain.
1099
+ */
1100
+ use(plugin, options) {
1101
+ if (typeof plugin !== "function") {
1102
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Plugin must be a function; got ${typeof plugin}`);
1103
+ }
1104
+ // Registration only. Edge defers plugins to the first render so one
1105
+ // registered before `mount()` or `configure()` still observes the engine
1106
+ // as it ends up, not as it was mid-boot.
1107
+ this.#plugins.push({
1108
+ run: (firstRun) => plugin(this, firstRun, options),
1109
+ options,
1110
+ executed: false,
680
1111
  });
1112
+ return this;
1113
+ }
1114
+ /**
1115
+ * Run the plugins that are due: each one once, plus every `recurring` plugin
1116
+ * again. `firstRun` lets a plugin split one-time registration from the
1117
+ * per-render work.
1118
+ */
1119
+ #executePlugins() {
1120
+ for (const plugin of this.#plugins) {
1121
+ if (plugin.executed && plugin.options?.recurring !== true)
1122
+ continue;
1123
+ const firstRun = !plugin.executed;
1124
+ // Set BEFORE calling: a plugin that renders would otherwise re-enter
1125
+ // here, see itself as pending, and recurse.
1126
+ plugin.executed = true;
1127
+ plugin.run(firstRun);
1128
+ }
1129
+ // Bundled last, exactly like Edge runs its own plugins after user-land
1130
+ // ones — a plugin may have mounted the disk we are about to scan.
1131
+ this.#refreshComponentTags();
1132
+ }
1133
+ /** The registered globals, as a read-only view (Edge `edge.globals`). */
1134
+ get globals() {
1135
+ return this.#globals;
1136
+ }
1137
+ /** The registered custom tags, as a read-only view (Edge `edge.tags`). */
1138
+ get tags() {
1139
+ return this.#tags;
1140
+ }
1141
+ createRenderer() {
1142
+ this.#executePlugins();
1143
+ const renderer = new TemplateRenderer(this);
1144
+ for (const callback of this.#renderCallbacks)
1145
+ callback(renderer);
1146
+ return renderer;
1147
+ }
1148
+ /**
1149
+ * Run `callback` against every renderer this engine creates (Edge
1150
+ * `onRender`). This is how a plugin seeds per-render state it cannot know at
1151
+ * registration time — the request, the signed-in user — without reaching
1152
+ * into the call site of every `createRenderer()`.
1153
+ */
1154
+ onRender(callback) {
1155
+ if (typeof callback !== "function") {
1156
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `onRender() expects a function; got ${typeof callback}`);
1157
+ }
1158
+ this.#renderCallbacks.push(callback);
1159
+ return this;
1160
+ }
1161
+ /**
1162
+ * Shorthand for `createRenderer().share(data)` (Edge `share`). The engine
1163
+ * itself holds no per-render state, so this hands back a renderer rather
1164
+ * than mutating the engine — sharing on the engine would leak one request's
1165
+ * data into the next.
1166
+ */
1167
+ share(data) {
1168
+ return this.createRenderer().share(data);
1169
+ }
1170
+ /**
1171
+ * Re-apply engine options after construction (Edge `configure`). Only the
1172
+ * options that can meaningfully change on a live engine are accepted: the
1173
+ * root is a containment boundary fixed at construction, and moving it would
1174
+ * invalidate every mounted disk's guarantees.
1175
+ *
1176
+ * Changing the cache mode drops the AST cache, since entries carry the
1177
+ * validation strategy they were stored under.
1178
+ */
1179
+ configure(options) {
1180
+ if (options.cacheMode === undefined)
1181
+ return;
1182
+ this.#cacheMode = resolveCacheMode(options.cacheMode);
1183
+ this.clearCache();
1184
+ }
1185
+ /**
1186
+ * Merge the registered globals UNDER the caller's data — per-render state
1187
+ * wins on a name collision, matching Edge's precedence. Returns `data`
1188
+ * untouched when nothing is registered, so the common path allocates nothing.
1189
+ */
1190
+ #withGlobals(data) {
1191
+ if (this.#globals.size === 0)
1192
+ return data;
1193
+ const merged = Object.create(null);
1194
+ for (const [name, value] of this.#globals)
1195
+ merged[name] = value;
1196
+ if (typeof data === "object" && data !== null && !Array.isArray(data)) {
1197
+ Object.assign(merged, data);
1198
+ }
1199
+ return merged;
1200
+ }
1201
+ /** Run the registered `raw` transforms over a template source. */
1202
+ #applyRaw(raw, path) {
1203
+ return this.processor.applyRaw(raw, path);
1204
+ }
1205
+ /** Run the registered `output` transforms over rendered HTML. */
1206
+ #applyOutput(output, template) {
1207
+ return this.processor.applyOutput(output, template);
1208
+ }
1209
+ /**
1210
+ * Parse a template from disk WITHOUT rendering it (Edge `compile`) — the
1211
+ * syntax check a linter or an editor integration wants.
1212
+ *
1213
+ * Throws {@link InkerRenderError} carrying `code`, `line` and `column` when
1214
+ * the template does not parse; returns nothing when it does.
1215
+ *
1216
+ * Named deviation, NAPI: Edge compiles to a JavaScript function and hands
1217
+ * it back. Inker's compiler is in Rust and produces an opaque native AST
1218
+ * handle, which has no meaning on this side of the bridge — so the method
1219
+ * reports whether the template parses instead of returning the artifact.
1220
+ */
1221
+ compile(name) {
1222
+ const { root, validated, absPath } = this.#resolveTemplateFile(name);
1223
+ this.#loadAstSync(absPath, validated, root);
1224
+ }
1225
+ /**
1226
+ * Parse a template STRING without rendering it (Edge `compileRaw`). See
1227
+ * {@link compile} for what it throws and why it returns nothing.
1228
+ *
1229
+ * `templateName` only labels the error, as it does upstream.
1230
+ */
1231
+ compileRaw(source, templateName) {
1232
+ // The same normalisation `renderString` applies before parsing: a BOM
1233
+ // would otherwise be reported as a syntax error the file does not have.
1234
+ const normalised = source.includes("\ufeff")
1235
+ ? source.replace(/\ufeff/g, "")
1236
+ : source;
1237
+ try {
1238
+ callNative(() => getNative().parseTemplate(this.#applyRaw(normalised), this.#parseNames(), [...this.#tags.keys()], this.#blockTagNames(), this.#componentTagsJson()));
1239
+ }
1240
+ catch (err) {
1241
+ // A string has no path of its own, so the caller's label is the only
1242
+ // thing that tells a reader WHICH template failed.
1243
+ if (templateName === undefined || !(err instanceof InkerRenderError))
1244
+ throw err;
1245
+ throw new InkerRenderError(err.code, err.message, { ...err.context, templateName }, { cause: err });
1246
+ }
1247
+ }
1248
+ /**
1249
+ * Render a template string (Edge `renderRawSync`). `renderString` is the
1250
+ * historical inker name and stays; this is the Edge-shaped alias.
1251
+ */
1252
+ renderRawSync(source, data = {}) {
1253
+ return this.renderString(source, data);
1254
+ }
1255
+ /**
1256
+ * Render a template string, asynchronously (Edge `renderRaw`). Inker parses
1257
+ * and renders synchronously, so this resolves immediately — it exists so code
1258
+ * written against Edge's async signature ports without a rewrite.
1259
+ */
1260
+ async renderRaw(source, data = {}) {
1261
+ return this.renderString(source, data);
1262
+ }
1263
+ /**
1264
+ * Register a template from memory (Edge `registerTemplate`). It resolves
1265
+ * under the name given — including a `components/…` or layout name — and
1266
+ * takes precedence over a file of the same name.
1267
+ */
1268
+ registerTemplate(name, contents) {
1269
+ if (typeof name !== "string" || name.length === 0) {
1270
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `registerTemplate — name must be a non-empty string; got ${typeof name}`);
1271
+ }
1272
+ if (typeof contents?.template !== "string") {
1273
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `registerTemplate('${name}') — contents.template must be a string`);
1274
+ }
1275
+ // Validate the name on the same rules as a disk lookup: an in-memory
1276
+ // template must not be reachable under a name a file could never carry,
1277
+ // or the two namespaces drift apart.
1278
+ this.#inMemory.set(validateName(name), contents.template);
1279
+ this.clearCache();
1280
+ }
1281
+ /** Drop a template registered from memory (Edge `removeTemplate`). */
1282
+ removeTemplate(name) {
1283
+ if (this.#inMemory.delete(validateName(name)))
1284
+ this.clearCache();
681
1285
  }
682
1286
  clearCache() {
683
1287
  this.#cacheGeneration += 1;
@@ -696,6 +1300,14 @@ export class Templates {
696
1300
  // entry (which passed containment against A's root) be served to disk B
697
1301
  // on a cache hit — bypassing B's symlink-containment check, which only
698
1302
  // runs on a cache MISS. The compound key isolates each disk's cache.
1303
+ // An in-memory template short-circuits the whole disk path: no stat, no
1304
+ // open, no mtime cache. It is parsed on each load — there is no file to
1305
+ // watch for staleness, and registerTemplate() already clears the cache.
1306
+ const inMemory = this.#inMemory.get(validatedName);
1307
+ if (inMemory !== undefined) {
1308
+ const source = this.#applyRaw(inMemory);
1309
+ return callNative(() => getNative().parseTemplate(source, this.#parseNames(), [...this.#tags.keys()], this.#blockTagNames(), this.#componentTagsJson()));
1310
+ }
699
1311
  const cacheKey = `${root}\u0000${absPath}`;
700
1312
  const inflight = this.#inflight.get(cacheKey);
701
1313
  if (inflight !== undefined)
@@ -763,21 +1375,7 @@ export class Templates {
763
1375
  }
764
1376
  let source;
765
1377
  try {
766
- let realPath;
767
- try {
768
- // `.native` to match validateRoot's canonical form (same OS realpath)
769
- // so 8.3-short-name / casing differences don't trip containment.
770
- realPath = fs.realpathSync.native(absPath);
771
- }
772
- catch (cause) {
773
- throw wrapFsError(cause, absPath, validatedName);
774
- }
775
- if (realPath !== absPath) {
776
- const rel = path.relative(root, realPath);
777
- if (rel !== "" && (rel.startsWith("..") || path.isAbsolute(rel))) {
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 });
779
- }
780
- }
1378
+ assertRealpathContained(absPath, root, validatedName);
781
1379
  try {
782
1380
  source = await handle.readFile("utf8");
783
1381
  }
@@ -788,14 +1386,22 @@ export class Templates {
788
1386
  finally {
789
1387
  await handle.close();
790
1388
  }
1389
+ return this.#parseAndCache(source, absPath, cacheKey, currentMtime, loadGeneration);
1390
+ }
1391
+ /**
1392
+ * Everything after the bytes are in hand: strip the BOM, run the `raw`
1393
+ * processors, parse, and cache. Shared by both loaders — only the four I/O
1394
+ * calls differ between them, and none of this should.
1395
+ */
1396
+ #parseAndCache(rawSource, absPath, cacheKey, currentMtime, loadGeneration) {
791
1397
  // T4: strip leading UTF-8 BOM if present. Windows editors (Notepad)
792
1398
  // commonly insert it; lex sees it as a Text token, defeating the
793
1399
  // "first non-stripped node must be Layout" composition rule and
794
1400
  // silently treating `@layout()` as body content.
795
- if (source.charCodeAt(0) === 0xfeff) {
796
- source = source.slice(1);
797
- }
798
- const ast = callNative(() => getNative().parseTemplate(source, [...this.#helperNames], [...this.#tags.keys()]));
1401
+ let source = rawSource.charCodeAt(0) === 0xfeff ? rawSource.slice(1) : rawSource;
1402
+ // `raw` processors see the file's source before it is parsed.
1403
+ source = this.#applyRaw(source, absPath);
1404
+ const ast = callNative(() => getNative().parseTemplate(source, this.#parseNames(), [...this.#tags.keys()], this.#blockTagNames(), this.#componentTagsJson()));
799
1405
  // T7: only populate the cache if the generation is unchanged. If
800
1406
  // clearCache() was called during the await chain above, the new
801
1407
  // generation discards this write — the next render() starts fresh.
@@ -804,7 +1410,75 @@ export class Templates {
804
1410
  }
805
1411
  return ast;
806
1412
  }
807
- async #compose(entryAst, entryName, entryAbsPath, includeStack) {
1413
+ /**
1414
+ * The synchronous twin of `#loadAstUncached`.
1415
+ *
1416
+ * Only the four I/O calls differ — `statSync`/`openSync`/`readFileSync`/
1417
+ * `closeSync` against their promise forms. The containment rule
1418
+ * (`assertRealpathContained`) and everything after the read
1419
+ * (`#parseAndCache`) are the SAME functions, so the two paths cannot drift
1420
+ * on the parts that matter.
1421
+ */
1422
+ #loadAstUncachedSync(absPath, validatedName, root) {
1423
+ const loadGeneration = this.#cacheGeneration;
1424
+ const cacheKey = `${root}\u0000${absPath}`;
1425
+ const cached = this.#cache.get(cacheKey);
1426
+ if (this.#cacheMode === "never" && cached !== undefined) {
1427
+ return cached.ast;
1428
+ }
1429
+ let currentMtime = 0;
1430
+ if (this.#cacheMode === "mtime") {
1431
+ try {
1432
+ currentMtime = fs.statSync(absPath).mtimeMs;
1433
+ }
1434
+ catch (cause) {
1435
+ throw wrapFsError(cause, absPath, validatedName);
1436
+ }
1437
+ // D1: mtimeMs 0 is a "no timestamp" sentinel on some filesystems —
1438
+ // caching on it would freeze the template forever. See the async twin.
1439
+ if (currentMtime !== 0 &&
1440
+ cached !== undefined &&
1441
+ cached.mtimeMs === currentMtime) {
1442
+ return cached.ast;
1443
+ }
1444
+ }
1445
+ // T6 + P1: open with `O_NOFOLLOW` first so a later path swap cannot
1446
+ // redirect the read, then validate what the OS resolved.
1447
+ let fd;
1448
+ try {
1449
+ fd = fs.openSync(absPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
1450
+ }
1451
+ catch (cause) {
1452
+ throw wrapFsError(cause, absPath, validatedName);
1453
+ }
1454
+ let source;
1455
+ try {
1456
+ assertRealpathContained(absPath, root, validatedName);
1457
+ try {
1458
+ source = fs.readFileSync(fd, "utf8");
1459
+ }
1460
+ catch (cause) {
1461
+ throw wrapFsError(cause, absPath, validatedName);
1462
+ }
1463
+ }
1464
+ finally {
1465
+ fs.closeSync(fd);
1466
+ }
1467
+ return this.#parseAndCache(source, absPath, cacheKey, currentMtime, loadGeneration);
1468
+ }
1469
+ /**
1470
+ * Synchronous `#loadAst`: the in-memory short-circuit, then the loader.
1471
+ * No in-flight map — a synchronous load cannot overlap another.
1472
+ */
1473
+ #loadAstSync(absPath, validatedName, root) {
1474
+ const inMemory = this.#inMemory.get(validatedName);
1475
+ if (inMemory !== undefined) {
1476
+ const source = this.#applyRaw(inMemory);
1477
+ return callNative(() => getNative().parseTemplate(source, this.#parseNames(), [...this.#tags.keys()], this.#blockTagNames(), this.#componentTagsJson()));
1478
+ }
1479
+ return this.#loadAstUncachedSync(absPath, validatedName, root);
1480
+ }
1481
+ *#compose(entryAst, entryName, entryAbsPath, includeStack) {
808
1482
  const partialAsts = new Map();
809
1483
  const componentAsts = new Map();
810
1484
  // The Rust parser already separates the leading `@layout()` into
@@ -830,8 +1504,8 @@ export class Templates {
830
1504
  // Resolve partials + components reachable from the body AST. Both
831
1505
  // resolvers recurse mutually, so the full transitive closure (partials
832
1506
  // inside components and vice versa) is pre-loaded here.
833
- await this.#resolvePartialsIn(entryInfo.partials, partialAsts, componentAsts, includeStack, entryAbsPath);
834
- await this.#resolveComponentsIn(entryInfo.components, partialAsts, componentAsts, includeStack, entryAbsPath);
1507
+ yield* this.#resolvePartialsIn(entryInfo.partials, partialAsts, componentAsts, includeStack, entryAbsPath);
1508
+ yield* this.#resolveComponentsIn(entryInfo.components, partialAsts, componentAsts, includeStack, entryAbsPath);
835
1509
  if (!hasLayout) {
836
1510
  return { bodyAst, partialAsts, componentAsts };
837
1511
  }
@@ -855,7 +1529,7 @@ export class Templates {
855
1529
  includeStack.add(layoutAbsPath);
856
1530
  let layoutAst;
857
1531
  try {
858
- layoutAst = await this.#loadAst(layoutAbsPath, layoutValidated, layoutRoot);
1532
+ layoutAst = askedForAst(yield loadRequest(layoutAbsPath, layoutValidated, layoutRoot));
859
1533
  }
860
1534
  catch (e) {
861
1535
  includeStack.delete(layoutAbsPath);
@@ -892,8 +1566,8 @@ export class Templates {
892
1566
  }
893
1567
  // Resolve partials + components reachable from the layout AST (mutual
894
1568
  // recursion covers the full transitive closure).
895
- await this.#resolvePartialsIn(layoutInfo.partials, partialAsts, componentAsts, includeStack, layoutAbsPath);
896
- await this.#resolveComponentsIn(layoutInfo.components, partialAsts, componentAsts, includeStack, layoutAbsPath);
1569
+ yield* this.#resolvePartialsIn(layoutInfo.partials, partialAsts, componentAsts, includeStack, layoutAbsPath);
1570
+ yield* this.#resolveComponentsIn(layoutInfo.components, partialAsts, componentAsts, includeStack, layoutAbsPath);
897
1571
  }
898
1572
  finally {
899
1573
  includeStack.delete(layoutAbsPath);
@@ -907,7 +1581,7 @@ export class Templates {
907
1581
  componentAsts,
908
1582
  };
909
1583
  }
910
- async #resolvePartialsIn(refs, partialAsts, componentAsts, includeStack, hostAbsPath) {
1584
+ *#resolvePartialsIn(refs, partialAsts, componentAsts, includeStack, hostAbsPath) {
911
1585
  for (const node of refs) {
912
1586
  const { root: partialRoot, validated: partialValidated, absPath: partialAbsPath, } = this.#resolveTemplateFile(node.name);
913
1587
  const partialKey = normalizePartialKey(node.name);
@@ -926,7 +1600,7 @@ export class Templates {
926
1600
  includeStack.add(partialAbsPath);
927
1601
  let partialAst;
928
1602
  try {
929
- partialAst = await this.#loadAst(partialAbsPath, partialValidated, partialRoot);
1603
+ partialAst = askedForAst(yield loadRequest(partialAbsPath, partialValidated, partialRoot));
930
1604
  }
931
1605
  catch (e) {
932
1606
  includeStack.delete(partialAbsPath);
@@ -955,15 +1629,15 @@ export class Templates {
955
1629
  partialAsts.set(partialKey, partialAst);
956
1630
  // Recurse into nested partials AND components reachable from this
957
1631
  // partial (mutual recursion → full transitive closure).
958
- await this.#resolvePartialsIn(info.partials, partialAsts, componentAsts, includeStack, partialAbsPath);
959
- await this.#resolveComponentsIn(info.components, partialAsts, componentAsts, includeStack, partialAbsPath);
1632
+ yield* this.#resolvePartialsIn(info.partials, partialAsts, componentAsts, includeStack, partialAbsPath);
1633
+ yield* this.#resolveComponentsIn(info.components, partialAsts, componentAsts, includeStack, partialAbsPath);
960
1634
  }
961
1635
  finally {
962
1636
  includeStack.delete(partialAbsPath);
963
1637
  }
964
1638
  }
965
1639
  }
966
- async #resolveComponentsIn(refs, partialAsts, componentAsts, includeStack, hostAbsPath) {
1640
+ *#resolveComponentsIn(refs, partialAsts, componentAsts, includeStack, hostAbsPath) {
967
1641
  for (const node of refs) {
968
1642
  // Split the optional `disk::` prefix off FIRST, then prepend the
969
1643
  // `components/` directory to the bare name so `disk::button` resolves
@@ -984,7 +1658,7 @@ export class Templates {
984
1658
  includeStack.add(componentAbsPath);
985
1659
  let componentAst;
986
1660
  try {
987
- componentAst = await this.#loadAst(componentAbsPath, componentValidated, componentRoot);
1661
+ componentAst = askedForAst(yield loadRequest(componentAbsPath, componentValidated, componentRoot));
988
1662
  }
989
1663
  catch (e) {
990
1664
  includeStack.delete(componentAbsPath);
@@ -1010,8 +1684,8 @@ export class Templates {
1010
1684
  // Recurse into nested components AND partials included inside this
1011
1685
  // component (mutual recursion → a @include() in a component is
1012
1686
  // pre-loaded, fixing E_INKER_DISK_REQUIRED at render time).
1013
- await this.#resolveComponentsIn(info.components, partialAsts, componentAsts, includeStack, componentAbsPath);
1014
- await this.#resolvePartialsIn(info.partials, partialAsts, componentAsts, includeStack, componentAbsPath);
1687
+ yield* this.#resolveComponentsIn(info.components, partialAsts, componentAsts, includeStack, componentAbsPath);
1688
+ yield* this.#resolvePartialsIn(info.partials, partialAsts, componentAsts, includeStack, componentAbsPath);
1015
1689
  }
1016
1690
  finally {
1017
1691
  includeStack.delete(componentAbsPath);
@@ -1028,4 +1702,71 @@ export class Templates {
1028
1702
  }
1029
1703
  }
1030
1704
  export default Templates;
1705
+ /**
1706
+ * A renderer with its own shared state (Edge `edge.createRenderer`).
1707
+ *
1708
+ * Created per request so `share()` state — the current URL, the signed-in user,
1709
+ * flash messages — reaches partials and components without touching the
1710
+ * process-wide engine. Two renderers never see each other's state.
1711
+ *
1712
+ * Precedence, lowest to highest: engine globals, this renderer's shared state,
1713
+ * then the data passed to the render call. The merge happens here, so the
1714
+ * engine needs no notion of who is rendering.
1715
+ */
1716
+ export class TemplateRenderer {
1717
+ #templates;
1718
+ #shared = Object.create(null);
1719
+ constructor(templates) {
1720
+ this.#templates = templates;
1721
+ }
1722
+ /** Merge `data` into this renderer's shared state (Edge `share`). Chainable. */
1723
+ share(data) {
1724
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
1725
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `share() expects an object; got ${data === null ? "null" : typeof data}`);
1726
+ }
1727
+ // Object.assign copies own enumerable keys only, onto a null-prototype
1728
+ // bag — a `__proto__` key lands as an own property instead of walking up
1729
+ // the prototype chain.
1730
+ Object.assign(this.#shared, data);
1731
+ return this;
1732
+ }
1733
+ render(name, data = {}) {
1734
+ return this.#templates.render(name, { ...this.#shared, ...data });
1735
+ }
1736
+ renderString(source, data = {}) {
1737
+ return this.#templates.renderString(source, { ...this.#shared, ...data });
1738
+ }
1739
+ /** Render a template from disk, synchronously (AdonisJS `renderSync`). */
1740
+ renderSync(name, data = {}) {
1741
+ return this.#templates.renderSync(name, { ...this.#shared, ...data });
1742
+ }
1743
+ /** Render a template string (Edge `renderRawSync`). Alias of `renderString`. */
1744
+ renderRawSync(source, data = {}) {
1745
+ return this.renderString(source, data);
1746
+ }
1747
+ /** Render a template string, asynchronously (Edge `renderRaw`). */
1748
+ async renderRaw(source, data = {}) {
1749
+ return this.renderString(source, data);
1750
+ }
1751
+ /**
1752
+ * A second renderer carrying a COPY of this one's shared state (Edge
1753
+ * `clone`). Used to branch — a nested render that needs one extra value
1754
+ * without that value leaking back into the renderer it came from.
1755
+ */
1756
+ clone() {
1757
+ return new TemplateRenderer(this.#templates).share(this.#shared);
1758
+ }
1759
+ /**
1760
+ * The state this renderer would render with: engine globals underneath, its
1761
+ * own shared values on top (Edge `getState`). Exists for assertions about
1762
+ * what a plugin or an `onRender` callback actually shared.
1763
+ */
1764
+ getState() {
1765
+ const state = Object.create(null);
1766
+ for (const [name, value] of this.#templates.globals)
1767
+ state[name] = value;
1768
+ Object.assign(state, this.#shared);
1769
+ return state;
1770
+ }
1771
+ }
1031
1772
  //# sourceMappingURL=Templates.js.map