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