@c9up/inker 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/dist/InkerProvider.d.ts +17 -9
  3. package/dist/InkerProvider.d.ts.map +1 -1
  4. package/dist/InkerProvider.js +56 -54
  5. package/dist/InkerProvider.js.map +1 -1
  6. package/dist/InkerRenderError.d.ts +1 -1
  7. package/dist/InkerRenderError.d.ts.map +1 -1
  8. package/dist/InkerRenderError.js.map +1 -1
  9. package/dist/InkerRenderer.d.ts +9 -0
  10. package/dist/InkerRenderer.d.ts.map +1 -1
  11. package/dist/InkerRenderer.js +13 -0
  12. package/dist/InkerRenderer.js.map +1 -1
  13. package/dist/Templates.d.ts +44 -0
  14. package/dist/Templates.d.ts.map +1 -1
  15. package/dist/Templates.js +271 -147
  16. package/dist/Templates.js.map +1 -1
  17. package/dist/globals.d.ts +10 -0
  18. package/dist/globals.d.ts.map +1 -0
  19. package/dist/globals.js +235 -0
  20. package/dist/globals.js.map +1 -0
  21. package/dist/identifierGuards.d.ts +2 -2
  22. package/dist/identifierGuards.js +2 -2
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/loadNapi.d.ts +6 -4
  27. package/dist/loadNapi.d.ts.map +1 -1
  28. package/dist/loadNapi.js +2 -2
  29. package/dist/loadNapi.js.map +1 -1
  30. package/dist/renderNode.d.ts +184 -0
  31. package/dist/renderNode.d.ts.map +1 -0
  32. package/dist/renderNode.js +479 -0
  33. package/dist/renderNode.js.map +1 -0
  34. package/index.darwin-arm64.node +0 -0
  35. package/index.darwin-x64.node +0 -0
  36. package/index.linux-arm64-gnu.node +0 -0
  37. package/index.linux-x64-gnu.node +0 -0
  38. package/index.win32-x64-msvc.node +0 -0
  39. package/package.json +3 -4
  40. package/scripts/copy-napi.mjs +0 -62
  41. package/src/InkerProvider.ts +0 -600
  42. package/src/InkerRenderError.ts +0 -49
  43. package/src/InkerRenderer.ts +0 -55
  44. package/src/SafeString.ts +0 -27
  45. package/src/Templates.ts +0 -1332
  46. package/src/helpers.ts +0 -76
  47. package/src/identifierGuards.ts +0 -49
  48. package/src/index.ts +0 -16
  49. package/src/loadNapi.ts +0 -270
  50. package/src/services/main.ts +0 -56
@@ -1,600 +0,0 @@
1
- /**
2
- * InkerProvider — Ream provider that wires `@c9up/inker` into a Ream host.
3
- *
4
- * `register()` binds an `InkerRenderer` singleton + the `"inker"` alias via
5
- * factories that throw pre-`start()` (so an accidental preload-time resolve
6
- * surfaces immediately instead of silently rendering with an unconfigured
7
- * Templates instance).
8
- *
9
- * `start()` lazily imports `@c9up/ream/services/router` + `@c9up/rosetta`
10
- * (both declared as `peerDependenciesMeta.optional`), builds the four
11
- * canonical helper bodies (`t` / `csrfField` / `url` / `asset`) closing
12
- * over a single `AsyncLocalStorage<InkerHttpContext>`, constructs the
13
- * `Templates` instance + `InkerRenderer`, and primes `services/main`'s
14
- * Proxy via `setInker`.
15
- *
16
- * Mirrors the StationProvider / AuroraProvider shape — duck-typed
17
- * container / config / app-context interfaces, `loadBearingCast<T>` as the
18
- * single sanctioned cross-package narrowing site, `isModuleNotFound`
19
- * silent-degradation in Phase 1, `#started` idempotency.
20
- */
21
-
22
- import { AsyncLocalStorage } from "node:async_hooks";
23
- import * as fs from "node:fs";
24
- import { isAbsolute, resolve as resolvePath } from "node:path";
25
- import { fileURLToPath } from "node:url";
26
- import type { HelperFn } from "./helpers.js";
27
- import type { InkerHttpContext } from "./InkerRenderer.js";
28
- import { InkerRenderer } from "./InkerRenderer.js";
29
- import { SafeString } from "./SafeString.js";
30
- import { setInker } from "./services/main.js";
31
- import { type CacheMode, Templates } from "./Templates.js";
32
-
33
- // ─── Duck-typed host interfaces ──────────────────────────────────
34
-
35
- interface InkerContainer {
36
- singleton<T>(token: unknown, factory: () => T): void;
37
- resolve<T = unknown>(token: unknown): T;
38
- }
39
-
40
- interface InkerConfigStore {
41
- get<T = unknown>(key: string): T | undefined;
42
- }
43
-
44
- export interface InkerAppContext {
45
- container: InkerContainer;
46
- config: InkerConfigStore;
47
- }
48
-
49
- // ─── Configuration shape (D14) ──────────────────────────────────
50
-
51
- export interface InkerProviderConfig {
52
- /** Absolute path or relative-to-appRoot. Default: <appRoot>/resources/templates. */
53
- templatesRoot?: string;
54
- /** "auto" (default) | "mtime" | "never". */
55
- cacheMode?: CacheMode;
56
- /** Optional manifest source for asset(). Direct injection beats <appRoot>/public/manifest.json. */
57
- assetManifest?: Readonly<Record<string, string>>;
58
- /** App-supplied helpers merged with canonical. Override warns once per name per process. */
59
- additionalHelpers?: Readonly<Record<string, HelperFn>>;
60
- }
61
-
62
- // ─── Peer-module shape duck-types ──────────────────────────────────
63
-
64
- interface ReamRouter {
65
- makeUrl(name: string, params?: Record<string, string>): string;
66
- }
67
-
68
- interface RosettaTranslator {
69
- t(
70
- key: string,
71
- params?: Record<
72
- string,
73
- string | number | boolean | Date | null | undefined
74
- >,
75
- options?: { locale?: string; defaultValue?: string },
76
- ): string;
77
- }
78
-
79
- // ─── Module-scoped flags (process-level, not instance-level) ─────────
80
-
81
- const overrideWarnEmittedNames = new Set<string>();
82
-
83
- /**
84
- * @internal Reset module-level flags between tests. The peer-missing and
85
- * cwd-fallback warns are now per-instance (audit 2026-06-13), so they reset
86
- * automatically with each new provider — this only clears the remaining
87
- * module-scoped override-warn set.
88
- */
89
- export function resetInkerProviderFlags(): void {
90
- overrideWarnEmittedNames.clear();
91
- }
92
-
93
- // ─── Provider class ──────────────────────────────────────────────
94
-
95
- export default class InkerProvider {
96
- #als: AsyncLocalStorage<InkerHttpContext> | undefined;
97
- #renderer: InkerRenderer | undefined;
98
- #started = false;
99
- // P17: per-instance override-warn dedup. Was a module-level Set shared
100
- // across every provider instance in the process — broke test isolation
101
- // and multi-tenant scenarios where each tenant has its own provider with
102
- // its own additionalHelpers map.
103
- readonly #overrideWarnedNames = new Set<string>();
104
- // Per-instance warn-once flags (audit 2026-06-13, same class as P17): module
105
- // -level flags meant a second provider in the same process silently skipped
106
- // its missing-peer / cwd-fallback diagnostic.
107
- #peerWarnEmitted = false;
108
- #appRootFallbackWarned = false;
109
-
110
- constructor(protected app: InkerAppContext) {}
111
-
112
- register(): void {
113
- this.app.container.singleton(InkerRenderer, () =>
114
- this.#getRendererOrThrow(),
115
- );
116
- this.app.container.singleton("inker", () =>
117
- this.app.container.resolve<InkerRenderer>(InkerRenderer),
118
- );
119
- }
120
-
121
- async boot(): Promise<void> {
122
- // No-op. Peers (Rosetta, Router) are resolved at start() — earlier
123
- // phases run before Ignitor finishes wiring the router proxy and
124
- // before RosettaProvider's boot loads catalogs.
125
- }
126
-
127
- async start(): Promise<void> {
128
- if (this.#started) return;
129
-
130
- // Phase 1 — lazy peer imports. Both `@c9up/ream/services/router` and
131
- // `@c9up/rosetta` are optional peers. Module-not-found is the
132
- // degraded-host signal: silently return + warn-once. Anything else
133
- // re-throws.
134
- let router: ReamRouter;
135
- let rosetta: RosettaTranslator;
136
- try {
137
- // Variable specifier so tsc does not statically resolve the optional
138
- // `@c9up/ream` peer at build time (keeps inker standalone-buildable).
139
- const routerSpecifier = "@c9up/ream/services/router";
140
- const routerMod: { default: ReamRouter } = await import(routerSpecifier);
141
- router = routerMod.default;
142
- const rosettaContainer = this.#resolveRosetta();
143
- if (rosettaContainer === undefined) {
144
- this.#warnPeerMissingOnce(
145
- "`@c9up/rosetta` is available as a module but no Rosetta instance is registered in the container. The `t()` helper will throw at first render.",
146
- );
147
- return;
148
- }
149
- rosetta = rosettaContainer;
150
- } catch (err) {
151
- if (isModuleNotFound(err)) {
152
- this.#warnPeerMissingOnce(
153
- "`@c9up/ream/services/router` or `@c9up/rosetta` is not installed. Inker rendering is disabled until both peers are present.",
154
- );
155
- return;
156
- }
157
- throw err;
158
- }
159
-
160
- // Phase 2 — resolve config.
161
- const config = this.app.config.get<InkerProviderConfig>("inker") ?? {};
162
- const appRoot = this.#readAppRoot();
163
- const templatesRoot = resolveTemplatesRoot(config.templatesRoot, appRoot);
164
- const cacheMode = resolveCacheMode(config.cacheMode);
165
- const assetManifest = loadAssetManifest(config.assetManifest, appRoot);
166
-
167
- // Phase 3 — build canonical helpers Map.
168
- const als = new AsyncLocalStorage<InkerHttpContext>();
169
- this.#als = als;
170
- const canonical = buildCanonicalHelpers(
171
- als,
172
- rosetta,
173
- router,
174
- assetManifest,
175
- );
176
-
177
- // Phase 4 — merge additional helpers (override-warn-once per instance).
178
- const merged = mergeHelpers(
179
- canonical,
180
- config.additionalHelpers,
181
- this.#overrideWarnedNames,
182
- );
183
-
184
- // Phase 5 — construct Templates + InkerRenderer + bind into proxy.
185
- const templates = new Templates({
186
- root: templatesRoot,
187
- cacheMode,
188
- helpers: merged,
189
- });
190
- const renderer = new InkerRenderer(templates, als);
191
- this.#renderer = renderer;
192
- setInker(renderer);
193
-
194
- this.#started = true;
195
- }
196
-
197
- async ready(): Promise<void> {}
198
-
199
- async shutdown(): Promise<void> {
200
- // Intentionally a no-op. `#started` guards `start()` from re-running,
201
- // so once the provider has booted, subsequent lifecycle calls have
202
- // nothing to undo here: `Templates` owns its own cache, AsyncLocalStorage
203
- // has no destroy contract, and the `setInker` singleton intentionally
204
- // outlives shutdown so late-arriving handlers don't see a torn-down
205
- // proxy. `Templates.clearCache()` is the operator's tool, not ours.
206
- }
207
-
208
- #getRendererOrThrow(): InkerRenderer {
209
- if (this.#renderer === undefined) {
210
- throw new Error(
211
- "[inker] InkerRenderer resolved before InkerProvider.start() ran. " +
212
- "Wait for the boot lifecycle to complete, or call `start()` manually.",
213
- );
214
- }
215
- return this.#renderer;
216
- }
217
-
218
- #warnPeerMissingOnce(detail: string): void {
219
- if (this.#peerWarnEmitted) return;
220
- this.#peerWarnEmitted = true;
221
- console.warn(`[inker] ${detail} See https://ream.dev/modules/inker.`);
222
- }
223
-
224
- #readAppRoot(): string {
225
- try {
226
- const raw = this.app.container.resolve<unknown>("appRoot");
227
- if (raw instanceof URL) return fileURLToPath(raw);
228
- if (typeof raw === "string") return raw;
229
- } catch (err) {
230
- // Only swallow the "no binding" path — re-throw factory errors so
231
- // host misconfiguration surfaces instead of being masked as a
232
- // cwd-fallback.
233
- if (!isContainerNotFound(err)) throw err;
234
- }
235
- if (!this.#appRootFallbackWarned) {
236
- this.#appRootFallbackWarned = true;
237
- console.warn(
238
- "[inker] No `appRoot` binding (URL or string) resolved from the container; falling back to process.cwd(). Templates and the asset manifest will be read relative to the process working directory — bind `appRoot` in the host container if that is not what you want.",
239
- );
240
- }
241
- return process.cwd();
242
- }
243
-
244
- #resolveRosetta(): RosettaTranslator | undefined {
245
- // Try container resolution under both the canonical "rosetta" alias
246
- // and the class binding. RosettaProvider binds both (per
247
- // `packages/rosetta/src/RosettaProvider.ts`).
248
- //
249
- // Only the "binding not registered" path is swallowed (host truly
250
- // lacks Rosetta — Phase 1 silently degrades). Factory-thrown errors
251
- // (catalog load failure, malformed YAML, etc.) re-throw — Station's
252
- // `#resolveDb` is loud for the same reason: surfacing operator
253
- // misconfiguration beats misdiagnosing it as "rosetta missing".
254
- const tokens: readonly string[] = ["rosetta", "Rosetta"];
255
- for (const token of tokens) {
256
- try {
257
- const candidate = this.app.container.resolve<unknown>(token);
258
- if (isRosettaShape(candidate)) {
259
- return candidate;
260
- }
261
- } catch (err) {
262
- if (isContainerNotFound(err)) continue;
263
- throw err;
264
- }
265
- }
266
- return undefined;
267
- }
268
- }
269
-
270
- // ─── Pure resolvers (exported @internal for unit tests) ──────────────
271
-
272
- /**
273
- * Resolve the templates root directory:
274
- * - missing / empty → `<appRoot>/resources/templates`
275
- * - absolute path → pass through
276
- * - relative path → joined to `appRoot`
277
- */
278
- export function resolveTemplatesRoot(
279
- userPath: string | undefined,
280
- appRoot: string,
281
- ): string {
282
- if (typeof userPath !== "string" || userPath.length === 0) {
283
- return resolvePath(appRoot, "resources/templates");
284
- }
285
- return isAbsolute(userPath) ? userPath : resolvePath(appRoot, userPath);
286
- }
287
-
288
- /**
289
- * Resolve the cache mode:
290
- * - explicit "mtime" / "never" → pass through
291
- * - "auto" / undefined → "never" in production, "mtime" otherwise
292
- * - anything else → throw (typo'd modes like `"Production"` or `"NEVER"`
293
- * should not silently downgrade to dev caching)
294
- */
295
- export function resolveCacheMode(
296
- userMode: CacheMode | string | undefined,
297
- ): "mtime" | "never" {
298
- if (userMode === "mtime" || userMode === "never") return userMode;
299
- if (userMode !== undefined && userMode !== "auto") {
300
- throw new Error(
301
- `[inker] config.inker.cacheMode must be "mtime", "never", "auto", or undefined; got ${JSON.stringify(userMode)}.`,
302
- );
303
- }
304
- return process.env.NODE_ENV === "production" ? "never" : "mtime";
305
- }
306
-
307
- /**
308
- * Load the asset manifest:
309
- * - injected value wins (returned verbatim — the caller's freezing applies)
310
- * - else read `<appRoot>/public/manifest.json` synchronously at boot
311
- * - else `undefined`
312
- *
313
- * Malformed manifests (non-object root, array, JSON parse error) → `undefined`.
314
- * Non-string entries inside a valid object are silently dropped (D8).
315
- */
316
- export function loadAssetManifest(
317
- injected: Readonly<Record<string, string>> | undefined,
318
- appRoot: string,
319
- ): Readonly<Record<string, string>> | undefined {
320
- if (injected !== undefined) return injected;
321
- const manifestPath = resolvePath(appRoot, "public/manifest.json");
322
- let raw: string;
323
- try {
324
- raw = fs.readFileSync(manifestPath, "utf8");
325
- } catch (err) {
326
- // P19: ENOENT is "no manifest configured" — silent absence is the
327
- // expected dev-without-build state. Any OTHER error (EACCES, EISDIR,
328
- // ELOOP, etc.) indicates a real misconfiguration that would otherwise
329
- // surface as a silent "every asset URL falls back to /_assets/foo"
330
- // degradation in prod. Warn so the operator sees the misconfig.
331
- const code =
332
- err instanceof Error ? (Reflect.get(err, "code") as unknown) : undefined;
333
- if (typeof code === "string" && code !== "ENOENT") {
334
- console.warn(
335
- `[inker] Failed to read asset manifest at ${manifestPath}: ${code}. asset() helpers will fall back to '/_assets/<path>' until this is resolved.`,
336
- );
337
- }
338
- return undefined;
339
- }
340
- let parsed: unknown;
341
- try {
342
- parsed = JSON.parse(raw);
343
- } catch {
344
- return undefined;
345
- }
346
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
347
- return undefined;
348
- }
349
- const out: Record<string, string> = Object.create(null);
350
- for (const [k, v] of Object.entries(parsed)) {
351
- if (typeof v === "string") out[k] = v;
352
- }
353
- return Object.freeze(out);
354
- }
355
-
356
- /**
357
- * Merge canonical + app-supplied helpers into one Map. Override warns once
358
- * per name per process. Function-type validation is local; helper-key
359
- * validation (identifier shape / reserved words / prototype-pollution
360
- * denylists) is delegated to the `Templates` constructor (53.4 AC1).
361
- */
362
- export function mergeHelpers(
363
- canonical: ReadonlyMap<string, HelperFn>,
364
- additional: Readonly<Record<string, HelperFn>> | undefined,
365
- // P17: optional per-instance warn-dedup set. Defaults to the module-level
366
- // set for backward compat with direct callers; InkerProvider now passes
367
- // its own per-instance `#overrideWarnedNames` so multi-tenant /
368
- // multi-provider setups don't share warn state. Tests that rely on the
369
- // module-level set still work via `resetInkerProviderFlags`.
370
- warnedNames: Set<string> = overrideWarnEmittedNames,
371
- ): Map<string, HelperFn> {
372
- const out = new Map(canonical);
373
- if (additional === undefined) return out;
374
- for (const [name, fn] of Object.entries(additional)) {
375
- if (typeof fn !== "function") {
376
- throw new Error(
377
- `[inker] additionalHelpers.${name} must be a function; got ${typeof fn}.`,
378
- );
379
- }
380
- if (out.has(name) && !warnedNames.has(name)) {
381
- warnedNames.add(name);
382
- console.warn(
383
- `[inker] additionalHelpers.${name} overrides the canonical helper. Suppressing further warnings for this name.`,
384
- );
385
- }
386
- out.set(name, fn);
387
- }
388
- return out;
389
- }
390
-
391
- /**
392
- * Coerce `url()` params: every value becomes a string via `String(v)`. Nullish
393
- * roots return `undefined` (no replacement map needed). Non-object roots and
394
- * arrays throw. Null / undefined / Symbol values throw rather than emit
395
- * silently-broken URLs like `/users/undefined`.
396
- */
397
- export function coerceUrlParams(
398
- raw: unknown,
399
- ): Record<string, string> | undefined {
400
- if (raw === undefined || raw === null) return undefined;
401
- if (typeof raw !== "object" || Array.isArray(raw)) {
402
- throw new Error(
403
- `[inker] url() params must be a plain object; got ${Array.isArray(raw) ? "array" : typeof raw}.`,
404
- );
405
- }
406
- // P9: Date objects pass the "is object, not array" check but `Object.entries`
407
- // returns `[]` for them — silently emitting an empty params Map and a URL
408
- // built from no replacements. Refuse explicitly with a hint pointing to
409
- // `toISOString()`.
410
- if (raw instanceof Date) {
411
- throw new Error(
412
- "[inker] url() params cannot be a Date instance — call `.toISOString()` first or wrap it in a plain object.",
413
- );
414
- }
415
- const out: Record<string, string> = Object.create(null);
416
- for (const [k, v] of Object.entries(raw)) {
417
- if (v === null || v === undefined) {
418
- throw new Error(
419
- `[inker] url() param '${k}' is ${v === null ? "null" : "undefined"} — omit the key or provide a value.`,
420
- );
421
- }
422
- if (typeof v === "symbol") {
423
- throw new Error(
424
- `[inker] url() param '${k}' is a Symbol — only stringifiable primitives are supported.`,
425
- );
426
- }
427
- // P8: NaN / +Infinity / -Infinity all stringify into URL-unfriendly
428
- // `"NaN"` / `"Infinity"` literals, producing routes like
429
- // `/users/NaN`. Authors usually arrive here via a downstream helper
430
- // that returned an unexpected non-finite value; surface it loud.
431
- if (typeof v === "number" && !Number.isFinite(v)) {
432
- throw new Error(
433
- `[inker] url() param '${k}' is ${Number.isNaN(v) ? "NaN" : v > 0 ? "Infinity" : "-Infinity"} — only finite numbers are supported.`,
434
- );
435
- }
436
- out[k] = String(v);
437
- }
438
- return out;
439
- }
440
-
441
- /**
442
- * 5-char HTML attribute-value escaper. Distinct from `escapeHtml` (text-node
443
- * use): attribute values need BOTH `"` and `'` escape so `value="…"` and
444
- * `value='…'` cannot be broken, while text-nodes don't need quote escapes
445
- * but do need `&` first to avoid double-escape.
446
- */
447
- export function escapeAttr(value: string): string {
448
- // P10: backtick added for parity with `escapeChar` in render.ts. Legacy
449
- // IE and some permissive parsers treat backtick as an attribute-value
450
- // delimiter inside unquoted attributes; we still emit quoted attributes
451
- // but encode it defensively in case a downstream rewrite drops the
452
- // quotes.
453
- return value
454
- .replace(/&/g, "&amp;")
455
- .replace(/</g, "&lt;")
456
- .replace(/>/g, "&gt;")
457
- .replace(/"/g, "&quot;")
458
- .replace(/'/g, "&#39;")
459
- .replace(/`/g, "&#96;");
460
- }
461
-
462
- /**
463
- * Build the four canonical helper bodies. Each closes over `als` + its
464
- * resolved peer + the (frozen) asset manifest. Helpers are SYNC — crossing
465
- * an async boundary would drop the ALS frame (53.4 D2).
466
- */
467
- export function buildCanonicalHelpers(
468
- als: AsyncLocalStorage<InkerHttpContext>,
469
- rosetta: RosettaTranslator,
470
- router: ReamRouter,
471
- assetManifest: Readonly<Record<string, string>> | undefined,
472
- ): Map<string, HelperFn> {
473
- const requireCtx = (helperName: string): InkerHttpContext => {
474
- const ctx = als.getStore();
475
- if (ctx === undefined) {
476
- throw new Error(
477
- `[inker] ${helperName}() invoked outside of an inker.render(ctx, …) call — store unavailable.`,
478
- );
479
- }
480
- return ctx;
481
- };
482
-
483
- const helpers = new Map<string, HelperFn>();
484
-
485
- helpers.set("t", (...args: readonly unknown[]): string => {
486
- const [key, params] = args;
487
- if (typeof key !== "string") {
488
- throw new Error(`[inker] t() requires a string key; got ${typeof key}.`);
489
- }
490
- const ctx = requireCtx("t");
491
- // Rosetta's TranslationParams is narrower than HelperFn's
492
- // `unknown[]` — the load-bearing narrow is the contract boundary;
493
- // Rosetta validates value types and throws on unsupported shapes.
494
- const rosettaParams =
495
- params === undefined
496
- ? undefined
497
- : loadBearingCast<
498
- Record<string, string | number | boolean | Date | null | undefined>
499
- >(params);
500
- return rosetta.t(key, rosettaParams, { locale: ctx.locale });
501
- });
502
-
503
- helpers.set("csrfField", (..._args: readonly unknown[]): SafeString => {
504
- const ctx = requireCtx("csrfField");
505
- const token = ctx.store.get("csrfToken");
506
- if (typeof token !== "string" || token.length === 0) {
507
- throw new Error(
508
- "[inker] csrfField() requires the @c9up/blackhole middleware with csrf enabled (csrfToken not found in ctx.store).",
509
- );
510
- }
511
- return new SafeString(
512
- `<input type="hidden" name="_csrf" value="${escapeAttr(token)}">`,
513
- );
514
- });
515
-
516
- helpers.set("csrfMeta", (..._args: readonly unknown[]): SafeString => {
517
- const ctx = requireCtx("csrfMeta");
518
- const token = ctx.store.get("csrfToken");
519
- if (typeof token !== "string" || token.length === 0) {
520
- throw new Error(
521
- "[inker] csrfMeta() requires the @c9up/blackhole middleware with csrf enabled (csrfToken not found in ctx.store).",
522
- );
523
- }
524
- return new SafeString(
525
- `<meta name="csrf-token" content="${escapeAttr(token)}">`,
526
- );
527
- });
528
-
529
- helpers.set("cspNonce", (..._args: readonly unknown[]): string => {
530
- const ctx = requireCtx("cspNonce");
531
- const nonce = ctx.store.get("cspNonce");
532
- // Non-throwing: CSP nonces are opt-in (only present when the CSP uses
533
- // `@nonce`), so an absent nonce yields an empty attribute, not an error.
534
- return typeof nonce === "string" ? nonce : "";
535
- });
536
-
537
- helpers.set("url", (...args: readonly unknown[]): string => {
538
- const [name, params] = args;
539
- if (typeof name !== "string") {
540
- throw new Error(
541
- `[inker] url() requires a string route name; got ${typeof name}.`,
542
- );
543
- }
544
- const coerced = coerceUrlParams(params);
545
- return router.makeUrl(name, coerced);
546
- });
547
-
548
- helpers.set("asset", (...args: readonly unknown[]): string => {
549
- const [name] = args;
550
- if (typeof name !== "string") {
551
- throw new Error(
552
- `[inker] asset() requires a string asset name; got ${typeof name}.`,
553
- );
554
- }
555
- return assetManifest?.[name] ?? `/_assets/${name}`;
556
- });
557
-
558
- return helpers;
559
- }
560
-
561
- // ─── Internal predicates / casts ──────────────────────────────────
562
-
563
- function isRosettaShape(value: unknown): value is RosettaTranslator {
564
- return (
565
- value !== null &&
566
- typeof value === "object" &&
567
- typeof Reflect.get(value, "t") === "function"
568
- );
569
- }
570
-
571
- /** Node's ERR_MODULE_NOT_FOUND surfaces on an Error subclass with `code`. */
572
- function isModuleNotFound(err: unknown): boolean {
573
- if (err === null || typeof err !== "object" || !("code" in err)) return false;
574
- const { code } = err;
575
- return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
576
- }
577
-
578
- /**
579
- * Ream's container throws a `ReamError` with `code === "CONTAINER_NOT_FOUND"`
580
- * when a token is unbound. Duck-typed here so `@c9up/ream` stays an optional
581
- * peer (no import-time dep on its error class).
582
- */
583
- function isContainerNotFound(err: unknown): boolean {
584
- if (err === null || typeof err !== "object" || !("code" in err)) return false;
585
- return err.code === "CONTAINER_NOT_FOUND";
586
- }
587
-
588
- /**
589
- * SANCTIONED CROSS-PACKAGE NARROWING — the ONE production site in
590
- * `@c9up/inker/provider` where `as T` is permitted. Memory
591
- * `feedback_no_any_types` is honoured by funnelling every load-bearing
592
- * narrow (dynamic peer imports, Rosetta params widened to Inker's HelperFn
593
- * shape) through this single function. Analogous to 54.2 AC15 / 54.1 AC9 /
594
- * `tests/__helpers__/bypass-type-check.ts`. Every call site MUST carry a
595
- * rationale comment explaining why static narrowing isn't expressible at
596
- * the boundary. NEVER widen this helper beyond `unknown → T`.
597
- */
598
- function loadBearingCast<T>(value: unknown): T {
599
- return value as T;
600
- }
@@ -1,49 +0,0 @@
1
- export type InkerErrorCode =
2
- | "E_INKER_TEMPLATE_NOT_FOUND"
3
- | "E_INKER_PARSE_ERROR"
4
- | "E_INKER_UNKNOWN_IDENTIFIER"
5
- | "E_INKER_INVALID_PATH"
6
- | "E_INKER_UNCLOSED_INTERPOLATION"
7
- | "E_INKER_UNCLOSED_BLOCK_TAG"
8
- | "E_INKER_UNKNOWN_DIRECTIVE"
9
- | "E_INKER_INVALID_LAYOUT_POSITION"
10
- | "E_INKER_DUPLICATE_LAYOUT"
11
- | "E_INKER_NESTED_LAYOUT_UNSUPPORTED"
12
- | "E_INKER_LAYOUT_IN_PARTIAL"
13
- | "E_INKER_CIRCULAR_INCLUDE"
14
- | "E_INKER_MISSING_SLOT"
15
- | "E_INKER_UNKNOWN_SLOT"
16
- | "E_INKER_DISK_REQUIRED"
17
- | "E_INKER_UNCLOSED_BLOCK"
18
- | "E_INKER_UNMATCHED_BLOCK_END"
19
- | "E_INKER_MISMATCHED_BLOCK_END"
20
- | "E_INKER_INVALID_EXPRESSION"
21
- | "E_INKER_INVALID_ITERABLE"
22
- | "E_INKER_UNKNOWN_HELPER"
23
- | "E_INKER_HELPER_THROW"
24
- | "E_INKER_NAPI_REQUIRED";
25
-
26
- export interface InkerErrorContext {
27
- readonly templatePath?: string;
28
- readonly templateName?: string;
29
- readonly line?: number;
30
- readonly column?: number;
31
- readonly expression?: string;
32
- }
33
-
34
- export class InkerRenderError extends Error {
35
- readonly code: InkerErrorCode;
36
- readonly context: Readonly<InkerErrorContext>;
37
-
38
- constructor(
39
- code: InkerErrorCode,
40
- message: string,
41
- context?: InkerErrorContext,
42
- options?: { cause?: unknown },
43
- ) {
44
- super(message, options);
45
- this.name = "InkerRenderError";
46
- this.code = code;
47
- this.context = Object.freeze({ ...(context ?? {}) });
48
- }
49
- }