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