@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.
- package/dist/InkerProvider.d.ts +23 -1
- package/dist/InkerProvider.d.ts.map +1 -1
- package/dist/InkerProvider.js +133 -2
- package/dist/InkerProvider.js.map +1 -1
- package/dist/InkerRenderError.d.ts +8 -1
- package/dist/InkerRenderError.d.ts.map +1 -1
- package/dist/InkerRenderError.js.map +1 -1
- package/dist/Templates.d.ts +235 -4
- package/dist/Templates.d.ts.map +1 -1
- package/dist/Templates.js +832 -91
- package/dist/Templates.js.map +1 -1
- package/dist/globals.d.ts +3 -3
- package/dist/globals.d.ts.map +1 -1
- package/dist/globals.js +190 -6
- package/dist/globals.js.map +1 -1
- package/dist/helpers.d.ts +11 -1
- package/dist/helpers.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/loadNapi.d.ts +6 -2
- package/dist/loadNapi.d.ts.map +1 -1
- package/dist/loadNapi.js.map +1 -1
- package/dist/renderNode.d.ts +99 -3
- package/dist/renderNode.d.ts.map +1 -1
- package/dist/renderNode.js +375 -57
- package/dist/renderNode.js.map +1 -1
- package/dist/stacks.d.ts +25 -0
- package/dist/stacks.d.ts.map +1 -0
- package/dist/stacks.js +97 -0
- package/dist/stacks.js.map +1 -0
- package/dist/testing/index.d.ts +42 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +50 -0
- package/dist/testing/index.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- 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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
879
|
+
const absPath = path.join(root, `${validated}${TEMPLATE_EXT}`);
|
|
584
880
|
assertContained(root, absPath, validated);
|
|
585
881
|
return { root, validated, absPath };
|
|
586
882
|
}
|
|
587
|
-
|
|
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(
|
|
594
|
-
const entryAst =
|
|
595
|
-
const composed =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
?
|
|
977
|
+
? askedForHtml(yield { nodes: layoutDefault, state, ctx: baseCtx })
|
|
625
978
|
: "";
|
|
626
|
-
sections.set(name,
|
|
627
|
-
|
|
628
|
-
|
|
979
|
+
sections.set(name, askedForHtml(yield {
|
|
980
|
+
nodes: sectionNodes,
|
|
981
|
+
state,
|
|
982
|
+
ctx: { ...baseCtx, superHtml },
|
|
629
983
|
}));
|
|
630
984
|
}
|
|
631
|
-
return
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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(
|
|
678
|
-
|
|
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
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
const ast = callNative(() => getNative().parseTemplate(source,
|
|
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
|
-
|
|
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
|
-
|
|
834
|
-
|
|
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 =
|
|
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
|
-
|
|
896
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1014
|
-
|
|
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
|