@c9up/inker 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/InkerProvider.d.ts +120 -0
  4. package/dist/InkerProvider.d.ts.map +1 -0
  5. package/dist/InkerProvider.js +448 -0
  6. package/dist/InkerProvider.js.map +1 -0
  7. package/dist/InkerRenderError.d.ts +16 -0
  8. package/dist/InkerRenderError.d.ts.map +1 -0
  9. package/dist/InkerRenderError.js +11 -0
  10. package/dist/InkerRenderError.js.map +1 -0
  11. package/dist/InkerRenderer.d.ts +28 -0
  12. package/dist/InkerRenderer.d.ts.map +1 -0
  13. package/dist/InkerRenderer.js +21 -0
  14. package/dist/InkerRenderer.js.map +1 -0
  15. package/dist/SafeString.d.ts +15 -0
  16. package/dist/SafeString.d.ts.map +1 -0
  17. package/dist/SafeString.js +24 -0
  18. package/dist/SafeString.js.map +1 -0
  19. package/dist/Templates.d.ts +16 -0
  20. package/dist/Templates.d.ts.map +1 -0
  21. package/dist/Templates.js +908 -0
  22. package/dist/Templates.js.map +1 -0
  23. package/dist/helpers.d.ts +50 -0
  24. package/dist/helpers.d.ts.map +1 -0
  25. package/dist/helpers.js +2 -0
  26. package/dist/helpers.js.map +1 -0
  27. package/dist/identifierGuards.d.ts +24 -0
  28. package/dist/identifierGuards.d.ts.map +1 -0
  29. package/dist/identifierGuards.js +49 -0
  30. package/dist/identifierGuards.js.map +1 -0
  31. package/dist/index.d.ts +5 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +4 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/loadNapi.d.ts +69 -0
  36. package/dist/loadNapi.d.ts.map +1 -0
  37. package/dist/loadNapi.js +145 -0
  38. package/dist/loadNapi.js.map +1 -0
  39. package/dist/services/main.d.ts +23 -0
  40. package/dist/services/main.d.ts.map +1 -0
  41. package/dist/services/main.js +49 -0
  42. package/dist/services/main.js.map +1 -0
  43. package/index.darwin-arm64.node +0 -0
  44. package/index.darwin-x64.node +0 -0
  45. package/index.linux-arm64-gnu.node +0 -0
  46. package/index.linux-x64-gnu.node +0 -0
  47. package/index.win32-x64-msvc.node +0 -0
  48. package/package.json +64 -0
  49. package/scripts/copy-napi.mjs +62 -0
  50. package/src/InkerProvider.ts +594 -0
  51. package/src/InkerRenderError.ts +49 -0
  52. package/src/InkerRenderer.ts +55 -0
  53. package/src/SafeString.ts +27 -0
  54. package/src/Templates.ts +1324 -0
  55. package/src/helpers.ts +57 -0
  56. package/src/identifierGuards.ts +49 -0
  57. package/src/index.ts +14 -0
  58. package/src/loadNapi.ts +270 -0
  59. package/src/services/main.ts +56 -0
@@ -0,0 +1,908 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { InkerRenderError } from "./InkerRenderError.js";
5
+ import { PROTOTYPE_POLLUTION_KEYS, RESERVED_BINDING_NAMES, } from "./identifierGuards.js";
6
+ import { getNative, napiThrowToInker, } from "./loadNapi.js";
7
+ import { SafeString } from "./SafeString.js";
8
+ const HELPER_NAME_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
9
+ // P13 — Windows-reserved device basenames. Refused on every platform for
10
+ // portability: a template named `con.inker` would resolve to the Windows
11
+ // console device handle, not a file.
12
+ const WINDOWS_RESERVED = new Set([
13
+ "con",
14
+ "prn",
15
+ "aux",
16
+ "nul",
17
+ "com1",
18
+ "com2",
19
+ "com3",
20
+ "com4",
21
+ "com5",
22
+ "com6",
23
+ "com7",
24
+ "com8",
25
+ "com9",
26
+ "lpt1",
27
+ "lpt2",
28
+ "lpt3",
29
+ "lpt4",
30
+ "lpt5",
31
+ "lpt6",
32
+ "lpt7",
33
+ "lpt8",
34
+ "lpt9",
35
+ ]);
36
+ const VALID_CACHE_MODES = new Set([
37
+ "auto",
38
+ "mtime",
39
+ "never",
40
+ ]);
41
+ function isErrnoException(value) {
42
+ return (value instanceof Error && typeof Reflect.get(value, "code") === "string");
43
+ }
44
+ function normalizePartialKey(name) {
45
+ let key = name;
46
+ while (key.startsWith("./"))
47
+ key = key.slice(2);
48
+ // T2: refuse an empty key — `{% include './' %}` would otherwise collide
49
+ // with the synthetic `<root>/.inker` dotfile path and silently include
50
+ // (or misreport) an unrelated file. validateName lets a literal `./`
51
+ // through (no `..`, no NUL, no backslash, length > 0), so the assertion
52
+ // must live here.
53
+ if (key.length === 0) {
54
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Partial/component name resolves to an empty key; got ${JSON.stringify(name)}`, { templateName: name });
55
+ }
56
+ return key;
57
+ }
58
+ function validateName(name) {
59
+ if (typeof name !== "string" || name.length === 0) {
60
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name must be a non-empty string; got ${JSON.stringify(name)}`, { templateName: typeof name === "string" ? name : undefined });
61
+ }
62
+ assertSafeCharacters(name);
63
+ assertSafePathShape(name);
64
+ assertNotReservedDeviceName(name);
65
+ return name;
66
+ }
67
+ /**
68
+ * Reject NUL, other control bytes (CR/LF/TAB/ESC etc.), the BOM, and lone
69
+ * surrogates. These pass through filesystems differently across platforms
70
+ * (ext4 vs NTFS), corrupt JSON serialisation of error context, and amplify
71
+ * ANSI-escape injection into messages that interpolate `templatePath`.
72
+ */
73
+ function assertSafeCharacters(name) {
74
+ if (name.includes("\0")) {
75
+ throw new InkerRenderError("E_INKER_INVALID_PATH", "Template name contains a NUL byte", { templateName: name });
76
+ }
77
+ for (let i = 0; i < name.length; i += 1) {
78
+ const code = name.charCodeAt(i);
79
+ // C0 controls (0x00 caught above), DEL, C1 controls
80
+ if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
81
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name contains a control character (0x${code.toString(16).padStart(2, "0")}) at offset ${i}; got ${JSON.stringify(name)}`, { templateName: name });
82
+ }
83
+ if (code === 0xfeff) {
84
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name contains a BOM (U+FEFF) at offset ${i}`, { templateName: name });
85
+ }
86
+ if (code >= 0xd800 && code <= 0xdbff) {
87
+ const next = name.charCodeAt(i + 1);
88
+ if (!(next >= 0xdc00 && next <= 0xdfff)) {
89
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name contains a lone high surrogate at offset ${i}`, { templateName: name });
90
+ }
91
+ i += 1;
92
+ }
93
+ else if (code >= 0xdc00 && code <= 0xdfff) {
94
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name contains a lone low surrogate at offset ${i}`, { templateName: name });
95
+ }
96
+ }
97
+ }
98
+ /**
99
+ * Reject absolute paths, `..` segments, backslashes, and Windows drive-letter
100
+ * prefixes — each would bypass the lexical `path.join(root, …)` containment.
101
+ * Mirrors parseBlockTag.validatePathName so `{% include %}` and the public
102
+ * Templates#render entrypoint agree.
103
+ */
104
+ function assertSafePathShape(name) {
105
+ if (path.isAbsolute(name)) {
106
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name must be relative to the templates root; got absolute path ${JSON.stringify(name)}`, { templateName: name });
107
+ }
108
+ if (name.split(/[/\\]/).some((segment) => segment === "..")) {
109
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name cannot contain '..' segments; got ${JSON.stringify(name)}`, { templateName: name });
110
+ }
111
+ if (name.includes("\\")) {
112
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name cannot contain backslashes (use forward slash only); got ${JSON.stringify(name)}`, { templateName: name });
113
+ }
114
+ if (/^[A-Za-z]:/.test(name)) {
115
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name cannot start with a Windows drive-letter prefix; got ${JSON.stringify(name)}`, { templateName: name });
116
+ }
117
+ }
118
+ /**
119
+ * Refuse Windows-reserved basenames (`con`, `prn`, `aux`, `nul`, `com1`-`com9`,
120
+ * `lpt1`-`lpt9`) on all platforms — they resolve to device handles on Windows
121
+ * and throw opaque non-Inker errors, so a template that works on Linux would
122
+ * fail mysteriously there.
123
+ */
124
+ function assertNotReservedDeviceName(name) {
125
+ for (const segment of name.split("/")) {
126
+ const base = segment.replace(/\.[^.]*$/, "").toLowerCase();
127
+ if (WINDOWS_RESERVED.has(base)) {
128
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Template name segment '${segment}' is a Windows-reserved device name`, { templateName: name });
129
+ }
130
+ }
131
+ }
132
+ function assertContained(root, absPath, name) {
133
+ // P12: case-sensitive `startsWith` breaks on APFS/HFS+/NTFS where
134
+ // `realpath` canonicalises segment casing — a root like
135
+ // `/Users/x/Templates` whose realpath returns `/users/x/templates`
136
+ // would fail every legitimate lookup. Switch to `path.relative`:
137
+ // when the target is under root, the relative path neither starts
138
+ // with `..` nor is absolute (Windows cross-drive case).
139
+ const normalised = path.resolve(absPath);
140
+ const rel = path.relative(root, normalised);
141
+ if (rel === "")
142
+ return; // identical path
143
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
144
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Resolved template path escapes the templates root: ${normalised} is outside ${root}`, { templatePath: normalised, templateName: name });
145
+ }
146
+ }
147
+ function wrapFsError(cause, absPath, name) {
148
+ if (isErrnoException(cause) && cause.code === "ENOENT") {
149
+ return new InkerRenderError("E_INKER_TEMPLATE_NOT_FOUND", `Template not found: ${absPath}`, { templatePath: absPath, templateName: name }, { cause });
150
+ }
151
+ if (isErrnoException(cause)) {
152
+ // EACCES / EISDIR / ELOOP / ENOTDIR — file exists but the path does
153
+ // not resolve to a readable regular file. Path-axis error, not
154
+ // missing-template.
155
+ return new InkerRenderError("E_INKER_INVALID_PATH", `Template path is not a readable file (${cause.code}): ${absPath}`, { templatePath: absPath, templateName: name }, { cause });
156
+ }
157
+ // Non-Errno failure (e.g. unexpected runtime error during stat/read) —
158
+ // don't lie about "template not found" since the file may well exist;
159
+ // surface as a generic path-axis failure with the underlying message.
160
+ const detail = cause instanceof Error ? cause.message : String(cause);
161
+ return new InkerRenderError("E_INKER_INVALID_PATH", `Failed to load template ${absPath}: ${detail}`, { templatePath: absPath, templateName: name }, { cause });
162
+ }
163
+ // Run a native (NAPI) call and translate any thrown `napi::Error` carrying the
164
+ // engine's JSON error envelope back into a typed `InkerRenderError` (preserving
165
+ // code / line / column / templateName). Without this, the raw napi error
166
+ // surfaces with `code === "GenericFailure"`.
167
+ function callNative(fn) {
168
+ try {
169
+ return fn();
170
+ }
171
+ catch (err) {
172
+ throw napiThrowToInker(err);
173
+ }
174
+ }
175
+ // JS `Map` / `Set` instances do not cross the NAPI boundary as serde_json
176
+ // values (a Map serialises to `{}`), so the renderer would see them as empty.
177
+ // Encode them into the array-of-pairs / array-of-values shapes the Rust
178
+ // renderer's destructured-`each` iteration expects (mirrors the pre-Rust TS
179
+ // renderer's `Map.entries()` / `Set` iteration). Plain objects and arrays are
180
+ // recursed (to catch nested Maps); Dates / class instances pass through so
181
+ // napi-rs serialises them as it did before.
182
+ // Guard against circular references: the Rust engine serialises the entire data
183
+ // tree across the NAPI boundary, so a cycle would otherwise overflow the stack
184
+ // here (or fail opaquely at the serde boundary). Surface a clear, catchable
185
+ // error instead. `seen` tracks the current ancestor chain (added on entry,
186
+ // removed on exit) so shared-but-acyclic subgraphs (a DAG) are not false-flagged.
187
+ function enterCycleGuard(value, seen) {
188
+ if (seen.has(value)) {
189
+ throw new InkerRenderError("E_INKER_INVALID_EXPRESSION", "render data contains a circular reference — Inker serialises the full data tree to the Rust engine and cannot encode cyclic structures");
190
+ }
191
+ seen.add(value);
192
+ }
193
+ const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
194
+ const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
195
+ function encodeData(value, seen = new WeakSet()) {
196
+ if (value === undefined) {
197
+ // An explicit `undefined` own-property is silently dropped by JSON
198
+ // encoding, after which the Rust engine treats the key as missing and
199
+ // throws E_INKER_UNKNOWN_IDENTIFIER. The pre-Rust TS engine rendered null
200
+ // and undefined identically (empty string, falsy). Normalise undefined to
201
+ // null to preserve that behavior; the engine already maps the `undefined`
202
+ // literal to null too.
203
+ return null;
204
+ }
205
+ if (value instanceof Map)
206
+ return encodeMap(value, seen);
207
+ if (value instanceof Set)
208
+ return encodeSet(value, seen);
209
+ if (Array.isArray(value))
210
+ return encodeArray(value, seen);
211
+ if (value !== null && typeof value === "object") {
212
+ const proto = Object.getPrototypeOf(value);
213
+ if (proto === Object.prototype || proto === null) {
214
+ return encodePlainObject(value, seen);
215
+ }
216
+ // Date, class instance, etc. — let napi-rs serialise as before.
217
+ return value;
218
+ }
219
+ if (typeof value === "bigint")
220
+ return encodeBigInt(value);
221
+ if (typeof value === "number" && !Number.isFinite(value)) {
222
+ // NaN / ±Infinity have no JSON representation (serde encodes them as null,
223
+ // which would render as empty). The pre-Rust TS engine rendered the literal
224
+ // "NaN" / "Infinity"; that is unreachable through the JSON boundary, so fail
225
+ // loudly instead of rendering empty.
226
+ throw new InkerRenderError("E_INKER_INVALID_EXPRESSION", `Cannot pass non-finite number ${value} as template data — NaN and Infinity have no representation across the engine boundary; format it via a helper before rendering`);
227
+ }
228
+ return value;
229
+ }
230
+ function encodeMap(value, seen) {
231
+ enterCycleGuard(value, seen);
232
+ const out = Array.from(value, ([k, v]) => [
233
+ encodeData(k, seen),
234
+ encodeData(v, seen),
235
+ ]);
236
+ seen.delete(value);
237
+ return out;
238
+ }
239
+ function encodeSet(value, seen) {
240
+ enterCycleGuard(value, seen);
241
+ const out = Array.from(value, (v) => encodeData(v, seen));
242
+ seen.delete(value);
243
+ return out;
244
+ }
245
+ /**
246
+ * Structural sharing: only allocate a new array if a descendant actually
247
+ * changed (a Map/Set was encoded). The common Map/Set-free data tree is
248
+ * returned by reference, avoiding a full deep clone on every render.
249
+ */
250
+ function encodeArray(value, seen) {
251
+ enterCycleGuard(value, seen);
252
+ let changed = false;
253
+ const out = new Array(value.length);
254
+ for (let i = 0; i < value.length; i++) {
255
+ // Sparse holes survive JSON encoding as `null`, which the Rust engine
256
+ // would silently iterate/index. The pre-Rust TS engine rejected holes
257
+ // with a typed error; restore that here (eager, since the hole is only
258
+ // visible JS-side — slightly stricter than the old lazy check, which
259
+ // only fired when the hole was actually iterated or indexed).
260
+ if (!(i in value)) {
261
+ seen.delete(value);
262
+ throw new InkerRenderError("E_INKER_INVALID_ITERABLE", `Sparse array hole at index ${i} — Inker does not support sparse arrays; fill holes with explicit values`);
263
+ }
264
+ const encoded = encodeData(value[i], seen);
265
+ if (encoded !== value[i])
266
+ changed = true;
267
+ out[i] = encoded;
268
+ }
269
+ seen.delete(value);
270
+ return changed ? out : value;
271
+ }
272
+ /** Encode a plain object's own entries, sharing the reference when unchanged. */
273
+ function encodePlainObject(value, seen) {
274
+ enterCycleGuard(value, seen);
275
+ let changed = false;
276
+ const out = {};
277
+ for (const [k, v] of Object.entries(value)) {
278
+ const encoded = encodeData(v, seen);
279
+ if (encoded !== v)
280
+ changed = true;
281
+ out[k] = encoded;
282
+ }
283
+ seen.delete(value);
284
+ return changed ? out : value;
285
+ }
286
+ /**
287
+ * `bigint` cannot cross the NAPI boundary (serde JSON has no bigint). Widen to
288
+ * `Number` when it round-trips exactly; refuse the lossy case rather than
289
+ * silently dropping precision (the pre-Rust TS engine used `String(value)`).
290
+ */
291
+ function encodeBigInt(value) {
292
+ if (value >= MIN_SAFE_BIGINT && value <= MAX_SAFE_BIGINT) {
293
+ return Number(value);
294
+ }
295
+ throw new InkerRenderError("E_INKER_INVALID_EXPRESSION", `Cannot pass bigint ${value} as template data — it exceeds Number.MAX_SAFE_INTEGER and cannot cross the engine boundary without precision loss; convert it to a string via a helper or a precomputed field`);
296
+ }
297
+ /**
298
+ * Validate `options.root` and return its canonical (realpath'd) absolute path.
299
+ * Refuses non-absolute, filesystem/drive-root, missing, non-directory, and
300
+ * non-canonicalisable roots — each would break the symlink-containment guard
301
+ * in #loadAst.
302
+ */
303
+ function canonicalizeTemplatesRoot(root) {
304
+ if (typeof root !== "string" || !path.isAbsolute(root)) {
305
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates root must be an absolute path; got ${JSON.stringify(root)}`, { templatePath: typeof root === "string" ? root : undefined });
306
+ }
307
+ // D3: refuse filesystem-root / drive-root values. With root = "/" on
308
+ // POSIX or "C:\" on Windows, assertContained's `startsWith(rootWithSep)`
309
+ // matches every absolute path and the symlink-containment guard
310
+ // degenerates to "anywhere on the volume". Operator misconfiguration —
311
+ // fail loudly at construction rather than serve traversal as a feature.
312
+ if (root === "/" || /^[A-Za-z]:[\\/]?$/.test(root)) {
313
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates root cannot be the filesystem/drive root; got ${JSON.stringify(root)}`, { templatePath: root });
314
+ }
315
+ let stat;
316
+ try {
317
+ stat = fs.statSync(root);
318
+ }
319
+ catch (cause) {
320
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates root does not exist: ${root}`, { templatePath: root }, { cause });
321
+ }
322
+ if (!stat.isDirectory()) {
323
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates root is not a directory: ${root}`, { templatePath: root });
324
+ }
325
+ // Canonicalize root via realpath so symlinked-target containment checks
326
+ // compare canonical-against-canonical paths in #loadAst.
327
+ // P6: realpath failure here is a hard error — `statSync(root)` succeeded
328
+ // two lines up, so realpath should not fail. Silently falling back to
329
+ // the un-canonical root caused the realpath containment check in
330
+ // #loadAst to compare a real-path against a possibly-symlinked root,
331
+ // producing false positives (legitimate templates rejected) for every
332
+ // caller — broken Inker without diagnostic.
333
+ try {
334
+ // `.native` (the OS realpath) so the root and per-template realpath in
335
+ // #loadAst use the SAME canonical form — on Windows it expands 8.3 short
336
+ // names (RUNNER~1 -> runneradmin) that JS-side realpath leaves as-is,
337
+ // which otherwise breaks the symlink-containment check on CI runners.
338
+ return fs.realpathSync.native(root);
339
+ }
340
+ catch (cause) {
341
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates root could not be canonicalised (realpath failed) although it exists: ${root}`, { templatePath: root }, { cause });
342
+ }
343
+ }
344
+ /**
345
+ * Validate the helper registry and return it alongside the frozen set of helper
346
+ * names used for parse-time validation. Rejects non-Map containers, non-string
347
+ * keys, non-callable values, invalid identifiers, reserved words, and
348
+ * prototype-pollution keys.
349
+ */
350
+ function validateHelpers(rawHelpers) {
351
+ const helpers = rawHelpers ?? new Map();
352
+ // P12 — validate the helpers container is actually a Map. The TS type
353
+ // promises ReadonlyMap, but a caller in plain JS (or via a typed bypass)
354
+ // could pass a plain object and hit a confusing "helpers.keys is not a
355
+ // function" at construction.
356
+ if (!(helpers instanceof Map)) {
357
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates.helpers must be a Map; got ${Object.prototype.toString.call(helpers).slice(8, -1)}`);
358
+ }
359
+ const helperNames = new Set();
360
+ for (const [key, value] of helpers) {
361
+ // T3: validate the key is a string BEFORE handing it to
362
+ // HELPER_NAME_RE.test(), which ToString-coerces and throws a raw
363
+ // TypeError for Symbol keys — leaking outside the typed-error
364
+ // contract. Map allows any key type at runtime; only strings make
365
+ // sense as helper names.
366
+ if (typeof key !== "string") {
367
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Helper name must be a string; got ${typeof key}`);
368
+ }
369
+ // P13 — validate each helper value is callable. Without this, a
370
+ // non-function would surface as a generic TypeError wrapped under
371
+ // E_INKER_HELPER_THROW at render-time, hiding the registration bug.
372
+ if (typeof value !== "function") {
373
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Helper '${key}' must be a function; got ${typeof value}`, { templateName: key });
374
+ }
375
+ if (!HELPER_NAME_RE.test(key)) {
376
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Helper name '${key}' is not a valid identifier (must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/)`, { templateName: key });
377
+ }
378
+ if (RESERVED_BINDING_NAMES.has(key)) {
379
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Helper name '${key}' is a reserved word`, { templateName: key });
380
+ }
381
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) {
382
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Helper name '${key}' is forbidden (prototype-pollution surface)`, { templateName: key });
383
+ }
384
+ helperNames.add(key);
385
+ }
386
+ return { helpers, helperNames };
387
+ }
388
+ export class Templates {
389
+ #root;
390
+ #cacheMode;
391
+ #cache = new Map();
392
+ #inflight = new Map();
393
+ // T7: monotonic counter bumped by clearCache(). #loadAstUncached snapshots
394
+ // it before doing async I/O and refuses to write back to the cache if the
395
+ // generation moved during the load — prevents a pre-clear in-flight load
396
+ // from silently re-populating the cache after clearCache() ran.
397
+ #cacheGeneration = 0;
398
+ #helpers;
399
+ #helperNames;
400
+ constructor(options) {
401
+ this.#root = canonicalizeTemplatesRoot(options.root);
402
+ const requested = options.cacheMode ?? "auto";
403
+ if (!VALID_CACHE_MODES.has(requested)) {
404
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Templates cacheMode must be one of 'auto' | 'mtime' | 'never'; got ${JSON.stringify(requested)}`);
405
+ }
406
+ if (requested === "auto") {
407
+ this.#cacheMode =
408
+ process.env.NODE_ENV === "production" ? "never" : "mtime";
409
+ }
410
+ else {
411
+ this.#cacheMode = requested;
412
+ }
413
+ // P14 reverted: `Templates#helpers` is intentionally a LIVE reference,
414
+ // documented by the `resolves helper implementation LIVE per call (D4)`
415
+ // regression test. Parse-time validation is fixed to the helper SET
416
+ // registered at ctor; the implementation behind each name can be swapped
417
+ // at runtime by the caller (catalogue hot-swap), so no defensive copy.
418
+ const { helpers, helperNames } = validateHelpers(options.helpers);
419
+ this.#helpers = helpers;
420
+ this.#helperNames = helperNames;
421
+ }
422
+ async render(name, data) {
423
+ const validated = validateName(name);
424
+ const absPath = path.join(this.#root, `${validated}.inker`);
425
+ assertContained(this.#root, absPath, validated);
426
+ const entryAst = await this.#loadAst(absPath, validated);
427
+ const composed = await this.#compose(entryAst, validated, absPath, new Set([absPath]));
428
+ const native = getNative();
429
+ const encoded = encodeData(data);
430
+ const partials = Object.fromEntries(composed.partialAsts);
431
+ const components = Object.fromEntries(composed.componentAsts);
432
+ // Body pass — collect helper invocations in render order, invoke them
433
+ // TS-side, then render consuming the resolved tape (ADR-007 as adapted
434
+ // for 55.1: collect→invoke→render, no V8 callback).
435
+ const bodyCtx = {
436
+ partials,
437
+ components,
438
+ bodyHtml: undefined,
439
+ templateName: validated,
440
+ templatePath: absPath,
441
+ };
442
+ const bodyTape = callNative(() => native.collectInvocations(composed.bodyAst, encoded, bodyCtx));
443
+ const bodyResolved = this.#invokeHelpers(bodyTape, validated, absPath);
444
+ const bodyHtml = callNative(() => native.renderAst(composed.bodyAst, encoded, bodyResolved, bodyCtx));
445
+ if (composed.layoutAst === undefined) {
446
+ return bodyHtml;
447
+ }
448
+ const layoutAst = composed.layoutAst;
449
+ const layoutName = composed.layoutName ?? validated;
450
+ const layoutPath = composed.layoutAbsPath ?? absPath;
451
+ // Layout collect ctx omits bodyHtml (slots carry no helpers); render ctx
452
+ // injects it. Both walk identically so the tape aligns.
453
+ const layoutTape = callNative(() => native.collectInvocations(layoutAst, encoded, {
454
+ partials,
455
+ components,
456
+ bodyHtml: undefined,
457
+ templateName: layoutName,
458
+ templatePath: layoutPath,
459
+ }));
460
+ const layoutResolved = this.#invokeHelpers(layoutTape, layoutName, layoutPath);
461
+ return callNative(() => native.renderAst(layoutAst, encoded, layoutResolved, {
462
+ partials,
463
+ components,
464
+ bodyHtml,
465
+ templateName: layoutName,
466
+ templatePath: layoutPath,
467
+ }));
468
+ }
469
+ renderString(source, data) {
470
+ // T4 + P15: strip ALL U+FEFF (BOM) characters, not just a leading one.
471
+ // `validateName` already refuses BOM in any position of a template
472
+ // name; source built by concatenating multiple BOM-prefixed fragments
473
+ // can leak interior BOMs into rendered HTML and confuse downstream
474
+ // parsers. Keep symmetry with #loadAstUncached on the leading case
475
+ // while extending coverage to internal occurrences.
476
+ const normalisedSource = source.includes("")
477
+ ? source.replace(//g, "")
478
+ : source;
479
+ const native = getNative();
480
+ const ast = callNative(() => native.parseTemplate(normalisedSource, [...this.#helperNames]));
481
+ const info = ast.composeInfo;
482
+ // The Rust parser separates a leading `{% layout %}` into `ast.layout`
483
+ // (not a body node), so `firstDiskNode` won't surface it — check
484
+ // `hasLayout` explicitly to preserve the renderString disk-required guard.
485
+ if (info.hasLayout) {
486
+ throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve {% layout '${info.layoutName ?? ""}' %} — use Templates#render(name, data) instead`);
487
+ }
488
+ const disk = info.firstDiskNode;
489
+ if (disk !== null && disk !== undefined) {
490
+ if (disk.kind === "Layout") {
491
+ throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve {% layout '${disk.name}' %} — use Templates#render(name, data) instead`);
492
+ }
493
+ if (disk.kind === "Partial") {
494
+ throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve {% include '${disk.name}' %} — use Templates#render(name, data) instead`);
495
+ }
496
+ if (disk.kind === "Component") {
497
+ throw new InkerRenderError("E_INKER_DISK_REQUIRED", `Templates#renderString cannot resolve {% component '${disk.name}' %} — use Templates#render(name, data) instead`);
498
+ }
499
+ 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
+ }
501
+ const ctx = {
502
+ partials: {},
503
+ components: {},
504
+ bodyHtml: undefined,
505
+ templateName: undefined,
506
+ templatePath: undefined,
507
+ };
508
+ const encoded = encodeData(data);
509
+ const tape = callNative(() => native.collectInvocations(ast, encoded, ctx));
510
+ const resolved = this.#invokeHelpers(tape, undefined, undefined);
511
+ return callNative(() => native.renderAst(ast, encoded, resolved, ctx));
512
+ }
513
+ // Invoke each collected helper TS-side in tape order, producing the resolved
514
+ // values the renderer consumes. Mirrors the old `render.ts` Call-arm
515
+ // contract: SafeString → raw; null/undefined → ""; non-string/SafeString or
516
+ // a throw / thenable → E_INKER_HELPER_THROW (preserving InkerRenderError
517
+ // passthrough + cause chain).
518
+ #invokeHelpers(tape, templateName, templatePath) {
519
+ const out = [];
520
+ for (const inv of tape) {
521
+ const helper = this.#helpers.get(inv.name);
522
+ if (helper === undefined) {
523
+ throw new InkerRenderError("E_INKER_UNKNOWN_HELPER", `Helper '${inv.name}' is not registered in this Templates instance`, { templatePath, templateName, expression: inv.name });
524
+ }
525
+ let result;
526
+ let thenProp;
527
+ try {
528
+ result = helper(...inv.args);
529
+ if (result !== null && typeof result === "object") {
530
+ thenProp = Reflect.get(result, "then");
531
+ }
532
+ }
533
+ catch (cause) {
534
+ if (cause instanceof InkerRenderError)
535
+ throw cause;
536
+ const message = cause instanceof Error ? cause.message : String(cause);
537
+ throw new InkerRenderError("E_INKER_HELPER_THROW", `Helper '${inv.name}' threw: ${message}`, { templatePath, templateName, expression: inv.name }, { cause });
538
+ }
539
+ if (typeof thenProp === "function") {
540
+ throw new InkerRenderError("E_INKER_HELPER_THROW", `Helper '${inv.name}' returned a Promise/thenable — Inker renderers are synchronous (D2)`, { templatePath, templateName, expression: inv.name });
541
+ }
542
+ if (result instanceof SafeString) {
543
+ out.push({ value: result.value, isSafe: true });
544
+ }
545
+ else if (result === null || result === undefined) {
546
+ out.push({ value: "", isSafe: false });
547
+ }
548
+ else if (typeof result === "string") {
549
+ out.push({ value: result, isSafe: false });
550
+ }
551
+ else {
552
+ throw new InkerRenderError("E_INKER_HELPER_THROW", `Helper '${inv.name}' returned ${typeof result} — Inker helpers must return string | SafeString | null | undefined (D2)`, { templatePath, templateName, expression: inv.name });
553
+ }
554
+ }
555
+ return out;
556
+ }
557
+ clearCache() {
558
+ this.#cacheGeneration += 1;
559
+ this.#cache.clear();
560
+ // T7: also drop the in-flight promise dedup map so the next render()
561
+ // for any in-flight key forces a fresh load instead of reusing the
562
+ // pre-clear promise. The promise itself still resolves for whoever
563
+ // awaited it (and may write its stale AST to the cache); the
564
+ // #cacheGeneration counter discards that write — see #loadAstUncached.
565
+ this.#inflight.clear();
566
+ }
567
+ async #loadAst(absPath, validatedName) {
568
+ const inflight = this.#inflight.get(absPath);
569
+ if (inflight !== undefined)
570
+ return inflight;
571
+ const promise = this.#loadAstUncached(absPath, validatedName);
572
+ this.#inflight.set(absPath, promise);
573
+ try {
574
+ return await promise;
575
+ }
576
+ finally {
577
+ this.#inflight.delete(absPath);
578
+ }
579
+ }
580
+ async #loadAstUncached(absPath, validatedName) {
581
+ // T7: snapshot the cache generation. If clearCache() runs while this
582
+ // load is in flight, the snapshot will diverge from #cacheGeneration
583
+ // at write-back time, and we'll skip the cache.set() to avoid
584
+ // silently restoring a stale AST after operator invalidation.
585
+ const loadGeneration = this.#cacheGeneration;
586
+ const cached = this.#cache.get(absPath);
587
+ if (this.#cacheMode === "never" && cached !== undefined) {
588
+ return cached.ast;
589
+ }
590
+ let currentMtime = 0;
591
+ if (this.#cacheMode === "mtime") {
592
+ try {
593
+ currentMtime = (await fsPromises.stat(absPath)).mtimeMs;
594
+ }
595
+ catch (cause) {
596
+ throw wrapFsError(cause, absPath, validatedName);
597
+ }
598
+ // D1: treat mtime === 0 as "no timestamp available" rather than a
599
+ // real value. Some FUSE filesystems, tar restores, and certain
600
+ // network mounts surface mtimeMs: 0 as a sentinel. If we treated
601
+ // that as a cacheable timestamp, the FIRST load would cache, and
602
+ // every subsequent disk edit would also report mtimeMs: 0 → cache
603
+ // hit → permanent silent staleness. Force a re-parse instead.
604
+ // Cerebrum DNR #61 forbids size/hash checks; this preserves the
605
+ // mtime-only spirit while handling the sentinel safely.
606
+ if (currentMtime !== 0 &&
607
+ cached !== undefined &&
608
+ cached.mtimeMs === currentMtime) {
609
+ return cached.ast;
610
+ }
611
+ }
612
+ // T6 + P1: open the file with `O_NOFOLLOW` first, then validate the
613
+ // canonical path against root, then read from the file handle. Previous
614
+ // approach did two separate awaits on `absPath` (`realpath` then
615
+ // `readFile`) — an attacker who swaps `absPath` for a symlink between
616
+ // the two awaits would bypass the containment check and have the
617
+ // content read follow the swapped link. Holding a FD pins the inode:
618
+ // after `open` succeeds, subsequent path swaps cannot redirect the
619
+ // read. `O_NOFOLLOW` additionally rejects the final segment being a
620
+ // symlink at open time. The `realpath` check after open still races on
621
+ // intermediate directory swaps but is now belt-and-suspenders rather
622
+ // than the only line of defence.
623
+ let handle;
624
+ try {
625
+ handle = await fsPromises.open(absPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
626
+ }
627
+ catch (cause) {
628
+ throw wrapFsError(cause, absPath, validatedName);
629
+ }
630
+ let source;
631
+ try {
632
+ let realPath;
633
+ try {
634
+ // `.native` to match validateRoot's canonical form (same OS realpath)
635
+ // so 8.3-short-name / casing differences don't trip containment.
636
+ realPath = fs.realpathSync.native(absPath);
637
+ }
638
+ catch (cause) {
639
+ throw wrapFsError(cause, absPath, validatedName);
640
+ }
641
+ if (realPath !== absPath) {
642
+ const rel = path.relative(this.#root, realPath);
643
+ if (rel !== "" && (rel.startsWith("..") || path.isAbsolute(rel))) {
644
+ throw new InkerRenderError("E_INKER_INVALID_PATH", `Resolved template path escapes the templates root via symlink: ${realPath} is outside ${this.#root}`, { templatePath: realPath, templateName: validatedName });
645
+ }
646
+ }
647
+ try {
648
+ source = await handle.readFile("utf8");
649
+ }
650
+ catch (cause) {
651
+ throw wrapFsError(cause, absPath, validatedName);
652
+ }
653
+ }
654
+ finally {
655
+ await handle.close();
656
+ }
657
+ // T4: strip leading UTF-8 BOM if present. Windows editors (Notepad)
658
+ // commonly insert it; lex sees it as a Text token, defeating the
659
+ // "first non-stripped node must be Layout" composition rule and
660
+ // silently treating `{% layout %}` as body content.
661
+ if (source.charCodeAt(0) === 0xfeff) {
662
+ source = source.slice(1);
663
+ }
664
+ const ast = callNative(() => getNative().parseTemplate(source, [...this.#helperNames]));
665
+ // T7: only populate the cache if the generation is unchanged. If
666
+ // clearCache() was called during the await chain above, the new
667
+ // generation discards this write — the next render() starts fresh.
668
+ if (this.#cacheGeneration === loadGeneration) {
669
+ this.#cache.set(absPath, { ast, mtimeMs: currentMtime });
670
+ }
671
+ return ast;
672
+ }
673
+ async #compose(entryAst, entryName, entryAbsPath, includeStack) {
674
+ const partialAsts = new Map();
675
+ const componentAsts = new Map();
676
+ // The Rust parser already separates the leading `{% layout %}` into
677
+ // `ast.layout` and excludes it from `ast.nodes`, so `entryAst` IS the
678
+ // body AST (no slice). Duplicate / mis-placed layout directives are
679
+ // rejected at parse time (parseTemplate throws E_INKER_DUPLICATE_LAYOUT /
680
+ // E_INKER_INVALID_LAYOUT_POSITION), so no body-side dup-layout check is
681
+ // needed here.
682
+ const entryInfo = entryAst.composeInfo;
683
+ const hasLayout = entryInfo.hasLayout;
684
+ const bodyAst = entryAst;
685
+ // Body ASTs must not contain Slot nodes: slots only mean something in a
686
+ // layout-yield context.
687
+ const bodySlot = entryInfo.slots[0];
688
+ if (bodySlot !== undefined) {
689
+ throw new InkerRenderError("E_INKER_UNKNOWN_SLOT", `{{> ${bodySlot.name} }} outside of a layout — slot placeholders are only valid inside layout files (got at line ${bodySlot.line}, column ${bodySlot.column} in '${entryName}')`, {
690
+ templatePath: entryAbsPath,
691
+ templateName: entryName,
692
+ line: bodySlot.line,
693
+ column: bodySlot.column,
694
+ });
695
+ }
696
+ // Resolve all partials reachable from the body AST (slot-in-partial
697
+ // rejection happens inside #resolvePartialsIn as each partial loads).
698
+ await this.#resolvePartialsIn(entryInfo.partials, partialAsts, includeStack, entryAbsPath);
699
+ // Resolve all components reachable from the body AST.
700
+ await this.#resolveComponentsIn(entryInfo.components, componentAsts, includeStack, entryAbsPath);
701
+ if (!hasLayout) {
702
+ // Resolve components transitively reachable from partials.
703
+ for (const partialAst of partialAsts.values()) {
704
+ await this.#resolveComponentsIn(partialAst.composeInfo.components, componentAsts, includeStack, entryAbsPath);
705
+ }
706
+ return { bodyAst, partialAsts, componentAsts };
707
+ }
708
+ // Resolve the layout file.
709
+ const layoutName = entryInfo.layoutName;
710
+ if (layoutName === null) {
711
+ // hasLayout true but no name — should be impossible (parse invariant).
712
+ throw new InkerRenderError("E_INKER_INVALID_LAYOUT_POSITION", `Internal: layout flagged but no layout name on '${entryName}'`, { templatePath: entryAbsPath, templateName: entryName });
713
+ }
714
+ const layoutLine = entryInfo.layoutLine ?? undefined;
715
+ const layoutColumn = entryInfo.layoutColumn ?? undefined;
716
+ const layoutValidated = validateName(layoutName);
717
+ const layoutAbsPath = path.join(this.#root, `${layoutValidated}.inker`);
718
+ assertContained(this.#root, layoutAbsPath, layoutValidated);
719
+ if (includeStack.has(layoutAbsPath)) {
720
+ throw new InkerRenderError("E_INKER_CIRCULAR_INCLUDE", `Circular include: ${this.#cycleString(includeStack, layoutAbsPath)} (started at ${entryAbsPath})`, {
721
+ templatePath: layoutAbsPath,
722
+ templateName: layoutValidated,
723
+ line: layoutLine,
724
+ column: layoutColumn,
725
+ });
726
+ }
727
+ includeStack.add(layoutAbsPath);
728
+ let layoutAst;
729
+ try {
730
+ layoutAst = await this.#loadAst(layoutAbsPath, layoutValidated);
731
+ }
732
+ catch (e) {
733
+ includeStack.delete(layoutAbsPath);
734
+ throw e;
735
+ }
736
+ try {
737
+ const layoutInfo = layoutAst.composeInfo;
738
+ // Nested-layout rejection.
739
+ if (layoutInfo.hasLayout) {
740
+ throw new InkerRenderError("E_INKER_NESTED_LAYOUT_UNSUPPORTED", `Layout file '${layoutValidated}' itself contains {% layout %} — nested layouts are not supported`, {
741
+ templatePath: layoutAbsPath,
742
+ templateName: layoutValidated,
743
+ line: layoutInfo.layoutLine ?? undefined,
744
+ column: layoutInfo.layoutColumn ?? undefined,
745
+ });
746
+ }
747
+ // Unknown-slot rejection (any slot whose name is not "body").
748
+ const unknownSlot = layoutInfo.slots.find((s) => s.name !== "body");
749
+ if (unknownSlot !== undefined) {
750
+ throw new InkerRenderError("E_INKER_UNKNOWN_SLOT", `Unknown slot '${unknownSlot.name}' — Inker 53.2 only supports {{> body }}. Named sections arrive in 53.3.`, {
751
+ templatePath: layoutAbsPath,
752
+ templateName: layoutValidated,
753
+ line: unknownSlot.line,
754
+ column: unknownSlot.column,
755
+ });
756
+ }
757
+ // Missing-slot rejection (D11) — only when the body has real content.
758
+ const hasBodySlot = layoutInfo.slots.some((s) => s.name === "body");
759
+ if (!hasBodySlot && entryInfo.hasContent) {
760
+ throw new InkerRenderError("E_INKER_MISSING_SLOT", `Layout '${layoutValidated}' has no {{> body }} placeholder, cannot render body of child '${entryName}'`, {
761
+ templatePath: layoutAbsPath,
762
+ templateName: layoutValidated,
763
+ });
764
+ }
765
+ // Resolve partials + components reachable from the layout AST.
766
+ await this.#resolvePartialsIn(layoutInfo.partials, partialAsts, includeStack, layoutAbsPath);
767
+ await this.#resolveComponentsIn(layoutInfo.components, componentAsts, includeStack, layoutAbsPath);
768
+ for (const partialAst of partialAsts.values()) {
769
+ await this.#resolveComponentsIn(partialAst.composeInfo.components, componentAsts, includeStack, layoutAbsPath);
770
+ }
771
+ }
772
+ finally {
773
+ includeStack.delete(layoutAbsPath);
774
+ }
775
+ return {
776
+ bodyAst,
777
+ layoutAst,
778
+ layoutName: layoutValidated,
779
+ layoutAbsPath,
780
+ partialAsts,
781
+ componentAsts,
782
+ };
783
+ }
784
+ async #resolvePartialsIn(refs, partialAsts, includeStack, hostAbsPath) {
785
+ for (const node of refs) {
786
+ const partialValidated = validateName(node.name);
787
+ const partialAbsPath = path.join(this.#root, `${partialValidated}.inker`);
788
+ assertContained(this.#root, partialAbsPath, partialValidated);
789
+ const partialKey = normalizePartialKey(node.name);
790
+ if (includeStack.has(partialAbsPath)) {
791
+ throw new InkerRenderError("E_INKER_CIRCULAR_INCLUDE", `Circular include: ${this.#cycleString(includeStack, partialAbsPath)} (referenced from ${hostAbsPath})`, {
792
+ templatePath: partialAbsPath,
793
+ templateName: partialValidated,
794
+ line: node.line,
795
+ column: node.column,
796
+ });
797
+ }
798
+ if (partialAsts.has(partialKey)) {
799
+ // Already resolved (different host re-referenced the same partial).
800
+ continue;
801
+ }
802
+ includeStack.add(partialAbsPath);
803
+ let partialAst;
804
+ try {
805
+ partialAst = await this.#loadAst(partialAbsPath, partialValidated);
806
+ }
807
+ catch (e) {
808
+ includeStack.delete(partialAbsPath);
809
+ throw e;
810
+ }
811
+ try {
812
+ const info = partialAst.composeInfo;
813
+ // Layout-in-partial rejection.
814
+ if (info.hasLayout) {
815
+ throw new InkerRenderError("E_INKER_LAYOUT_IN_PARTIAL", `Partial file '${partialValidated}' contains {% layout %} — partials cannot declare layouts`, {
816
+ templatePath: partialAbsPath,
817
+ templateName: partialValidated,
818
+ line: info.layoutLine ?? undefined,
819
+ column: info.layoutColumn ?? undefined,
820
+ });
821
+ }
822
+ // Slot-in-partial rejection: slots only mean something in layouts.
823
+ const slot = info.slots[0];
824
+ if (slot !== undefined) {
825
+ throw new InkerRenderError("E_INKER_UNKNOWN_SLOT", `Partial '${partialValidated}' contains {{> ${slot.name} }} — slot placeholders are only valid inside layout files (line ${slot.line}, column ${slot.column})`, {
826
+ templateName: partialValidated,
827
+ line: slot.line,
828
+ column: slot.column,
829
+ });
830
+ }
831
+ partialAsts.set(partialKey, partialAst);
832
+ // Recurse into nested partials.
833
+ await this.#resolvePartialsIn(info.partials, partialAsts, includeStack, partialAbsPath);
834
+ }
835
+ finally {
836
+ includeStack.delete(partialAbsPath);
837
+ }
838
+ }
839
+ }
840
+ async #resolveComponentsIn(refs, componentAsts, includeStack, hostAbsPath) {
841
+ for (const node of refs) {
842
+ const componentName = `components/${node.name}`;
843
+ const componentValidated = validateName(componentName);
844
+ const componentAbsPath = path.join(this.#root, `${componentValidated}.inker`);
845
+ assertContained(this.#root, componentAbsPath, componentValidated);
846
+ const componentKey = normalizePartialKey(node.name);
847
+ if (includeStack.has(componentAbsPath)) {
848
+ throw new InkerRenderError("E_INKER_CIRCULAR_INCLUDE", `Circular include: ${this.#cycleString(includeStack, componentAbsPath)} (referenced from ${hostAbsPath})`, {
849
+ templatePath: componentAbsPath,
850
+ templateName: componentValidated,
851
+ line: node.line,
852
+ column: node.column,
853
+ });
854
+ }
855
+ if (componentAsts.has(componentKey)) {
856
+ continue;
857
+ }
858
+ includeStack.add(componentAbsPath);
859
+ let componentAst;
860
+ try {
861
+ componentAst = await this.#loadAst(componentAbsPath, componentValidated);
862
+ }
863
+ catch (e) {
864
+ includeStack.delete(componentAbsPath);
865
+ throw e;
866
+ }
867
+ try {
868
+ const info = componentAst.composeInfo;
869
+ // Layout-in-component rejection (reuse E_INKER_LAYOUT_IN_PARTIAL
870
+ // per AC5: same axis "layout in non-entry file").
871
+ if (info.hasLayout) {
872
+ throw new InkerRenderError("E_INKER_LAYOUT_IN_PARTIAL", `Component file '${componentValidated}' contains {% layout %} — components cannot declare layouts`, {
873
+ templatePath: componentAbsPath,
874
+ templateName: componentValidated,
875
+ line: info.layoutLine ?? undefined,
876
+ column: info.layoutColumn ?? undefined,
877
+ });
878
+ }
879
+ // Slot-leak rejection: components MUST NOT contain {{> body }}.
880
+ const slot = info.slots[0];
881
+ if (slot !== undefined) {
882
+ throw new InkerRenderError("E_INKER_UNKNOWN_SLOT", `Component '${componentValidated}' contains {{> ${slot.name} }} — slot placeholders are only valid inside layout files (line ${slot.line}, column ${slot.column})`, {
883
+ templatePath: componentAbsPath,
884
+ templateName: componentValidated,
885
+ line: slot.line,
886
+ column: slot.column,
887
+ });
888
+ }
889
+ componentAsts.set(componentKey, componentAst);
890
+ // Recurse into nested components.
891
+ await this.#resolveComponentsIn(info.components, componentAsts, includeStack, componentAbsPath);
892
+ }
893
+ finally {
894
+ includeStack.delete(componentAbsPath);
895
+ }
896
+ }
897
+ }
898
+ #cycleString(includeStack, revisited) {
899
+ const stackList = Array.from(includeStack);
900
+ const revisitedIdx = stackList.indexOf(revisited);
901
+ const cycleFrames = revisitedIdx >= 0 ? stackList.slice(revisitedIdx) : stackList;
902
+ const rel = cycleFrames.map((p) => path.relative(this.#root, p));
903
+ const relRevisited = path.relative(this.#root, revisited);
904
+ return `${rel.join(" → ")} → ${relRevisited}`;
905
+ }
906
+ }
907
+ export default Templates;
908
+ //# sourceMappingURL=Templates.js.map