@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C9up
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @c9up/inker
2
+
3
+ Server-side templating module for the Ream framework. Loads `.inker` files from disk, parses them with a hand-rolled lexer + AST, and renders against a plain data object. HTML-escape by default; raw output via the explicit triple-brace form. Strict-by-default: unknown identifiers throw rather than render blank.
4
+
5
+ ## File convention
6
+
7
+ Templates live as `<root>/<name>.inker` files. Resolve the root yourself (absolute path) and pass it once at construction:
8
+
9
+ ```ts
10
+ import { Templates } from "@c9up/inker";
11
+
12
+ const templates = new Templates({ root: "/abs/path/to/templates" });
13
+ const html = await templates.render("invoice", { customer: { name: "Alice" }, total: 42 });
14
+ ```
15
+
16
+ Interpolation is `{{ expr }}` (HTML-escaped) or `{{{ expr }}}` (raw). The `expr` is a member-access path (`customer.name`, `items[0].title`, `items["weird key"]`); arithmetic, calls, ternaries, and template literals go through registered helpers.
17
+
18
+ ## Strict by default
19
+
20
+ - Missing templates throw `InkerRenderError` with `code: "E_INKER_TEMPLATE_NOT_FOUND"`.
21
+ - Unknown identifiers throw `code: "E_INKER_UNKNOWN_IDENTIFIER"` with the consumed path and the line + column of the offending interpolation.
22
+ - Parse errors throw `code: "E_INKER_PARSE_ERROR"` with a precise reason.
23
+
24
+ The full reference (file layout, cache semantics, error surface) lives at <https://ream.dev/modules/inker>.
25
+
26
+ ## Testing
27
+
28
+ ```sh
29
+ pnpm --filter @c9up/inker test # full suite
30
+ pnpm --filter @c9up/inker test:coverage # enforces v8 coverage gate
31
+ ```
32
+
33
+ The coverage gate (v8 provider) is wired in `vitest.config.ts` with thresholds at `statements: 88 / functions: 96 / branches: 78 / lines: 89` (re-baselined for the Rust-migration src/ surface — the lex/parse/render modules moved to Rust, covered by 105 `cargo test` cases). A regression that drops below any of those floors fails CI.
34
+
35
+ ## Native binary
36
+
37
+ The lex / parse / render hot path runs in Rust via napi-rs (Story 55.1). The TypeScript surface (`Templates`, `InkerProvider`, `SafeString`, `InkerRenderError`) is unchanged — the engine is loaded transparently from a prebuilt `.node` binary.
38
+
39
+ - **Build locally:** `pnpm --filter @c9up/inker build:napi` compiles the `inker-engine-napi` crate (release) and copies `index.<platform>.node` into the package root. The 5-platform NAPI CI matrix (`linux-x64-gnu`, `linux-arm64-gnu`, `darwin-x64`, `darwin-arm64`, `win32-x64-msvc`) builds these on native runners.
40
+ - **No JS fallback.** If the binary is missing or fails to load, every render throws `E_INKER_NAPI_REQUIRED` with an actionable hint pointing at `pnpm --filter @c9up/inker build:napi`. Run that after a fresh checkout or a platform change.
41
+ - **Helpers stay in TypeScript.** Custom helpers registered via `TemplatesOptions.helpers` (or `InkerProvider`) are plain TS functions. The renderer resolves them TS-side before the native render pass (collect → invoke → render — no V8 callback), so no Rust knowledge is required to write one. Helpers must appear as a whole interpolation (`{{ helper(args) }}`) or a component-arg value; they are not supported inside `{% if %}` conditions, `{% each %}` iterables, operator expressions, or as nested-call arguments. Helper **arguments** are evaluated in the Rust engine and cross the NAPI boundary as JSON, so they are JSON-coerced before the helper runs: a `Date` arrives as a string, a `bigint` as a (possibly lossy) number, and `NaN`/`±Infinity` as `null`. Pass pre-stringified values for any type that does not survive JSON.
42
+
43
+ ## Standalone use
44
+
45
+ `@c9up/inker` is a leaf package — it has zero runtime dependencies and works in any Node.js app without `@c9up/ream` or `@c9up/rosetta` installed. The `tests/integration/standalone-smoke.test.ts` test proves this by packing the workspace tarball, installing it into a synthetic consumer (no ream, no rosetta), and rendering a composite template.
46
+
47
+ The `@c9up/inker/provider` sub-path (the `InkerProvider` class) is importable without those peers as well — its `InkerAppContext` is duck-typed, so structural import never reaches the ream runtime. Wiring the provider into a real container still requires a Ream host at boot time.
48
+
@@ -0,0 +1,120 @@
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
+ import { AsyncLocalStorage } from "node:async_hooks";
22
+ import type { HelperFn } from "./helpers.js";
23
+ import type { InkerHttpContext } from "./InkerRenderer.js";
24
+ import { type CacheMode } from "./Templates.js";
25
+ interface InkerContainer {
26
+ singleton<T>(token: unknown, factory: () => T): void;
27
+ resolve<T = unknown>(token: unknown): T;
28
+ }
29
+ interface InkerConfigStore {
30
+ get<T = unknown>(key: string): T | undefined;
31
+ }
32
+ export interface InkerAppContext {
33
+ container: InkerContainer;
34
+ config: InkerConfigStore;
35
+ }
36
+ export interface InkerProviderConfig {
37
+ /** Absolute path or relative-to-appRoot. Default: <appRoot>/resources/templates. */
38
+ templatesRoot?: string;
39
+ /** "auto" (default) | "mtime" | "never". */
40
+ cacheMode?: CacheMode;
41
+ /** Optional manifest source for asset(). Direct injection beats <appRoot>/public/manifest.json. */
42
+ assetManifest?: Readonly<Record<string, string>>;
43
+ /** App-supplied helpers merged with canonical. Override warns once per name per process. */
44
+ additionalHelpers?: Readonly<Record<string, HelperFn>>;
45
+ }
46
+ interface ReamRouter {
47
+ makeUrl(name: string, params?: Record<string, string>): string;
48
+ }
49
+ interface RosettaTranslator {
50
+ t(key: string, params?: Record<string, string | number | boolean | Date | null | undefined>, options?: {
51
+ locale?: string;
52
+ defaultValue?: string;
53
+ }): string;
54
+ }
55
+ /** @internal Reset module-level flags between tests. */
56
+ export declare function resetInkerProviderFlags(): void;
57
+ export default class InkerProvider {
58
+ #private;
59
+ protected app: InkerAppContext;
60
+ constructor(app: InkerAppContext);
61
+ register(): void;
62
+ boot(): Promise<void>;
63
+ start(): Promise<void>;
64
+ ready(): Promise<void>;
65
+ shutdown(): Promise<void>;
66
+ }
67
+ /**
68
+ * Resolve the templates root directory:
69
+ * - missing / empty → `<appRoot>/resources/templates`
70
+ * - absolute path → pass through
71
+ * - relative path → joined to `appRoot`
72
+ */
73
+ export declare function resolveTemplatesRoot(userPath: string | undefined, appRoot: string): string;
74
+ /**
75
+ * Resolve the cache mode:
76
+ * - explicit "mtime" / "never" → pass through
77
+ * - "auto" / undefined → "never" in production, "mtime" otherwise
78
+ * - anything else → throw (typo'd modes like `"Production"` or `"NEVER"`
79
+ * should not silently downgrade to dev caching)
80
+ */
81
+ export declare function resolveCacheMode(userMode: CacheMode | string | undefined): "mtime" | "never";
82
+ /**
83
+ * Load the asset manifest:
84
+ * - injected value wins (returned verbatim — the caller's freezing applies)
85
+ * - else read `<appRoot>/public/manifest.json` synchronously at boot
86
+ * - else `undefined`
87
+ *
88
+ * Malformed manifests (non-object root, array, JSON parse error) → `undefined`.
89
+ * Non-string entries inside a valid object are silently dropped (D8).
90
+ */
91
+ export declare function loadAssetManifest(injected: Readonly<Record<string, string>> | undefined, appRoot: string): Readonly<Record<string, string>> | undefined;
92
+ /**
93
+ * Merge canonical + app-supplied helpers into one Map. Override warns once
94
+ * per name per process. Function-type validation is local; helper-key
95
+ * validation (identifier shape / reserved words / prototype-pollution
96
+ * denylists) is delegated to the `Templates` constructor (53.4 AC1).
97
+ */
98
+ export declare function mergeHelpers(canonical: ReadonlyMap<string, HelperFn>, additional: Readonly<Record<string, HelperFn>> | undefined, warnedNames?: Set<string>): Map<string, HelperFn>;
99
+ /**
100
+ * Coerce `url()` params: every value becomes a string via `String(v)`. Nullish
101
+ * roots return `undefined` (no replacement map needed). Non-object roots and
102
+ * arrays throw. Null / undefined / Symbol values throw rather than emit
103
+ * silently-broken URLs like `/users/undefined`.
104
+ */
105
+ export declare function coerceUrlParams(raw: unknown): Record<string, string> | undefined;
106
+ /**
107
+ * 5-char HTML attribute-value escaper. Distinct from `escapeHtml` (text-node
108
+ * use): attribute values need BOTH `"` and `'` escape so `value="…"` and
109
+ * `value='…'` cannot be broken, while text-nodes don't need quote escapes
110
+ * but do need `&` first to avoid double-escape.
111
+ */
112
+ export declare function escapeAttr(value: string): string;
113
+ /**
114
+ * Build the four canonical helper bodies. Each closes over `als` + its
115
+ * resolved peer + the (frozen) asset manifest. Helpers are SYNC — crossing
116
+ * an async boundary would drop the ALS frame (53.4 D2).
117
+ */
118
+ export declare function buildCanonicalHelpers(als: AsyncLocalStorage<InkerHttpContext>, rosetta: RosettaTranslator, router: ReamRouter, assetManifest: Readonly<Record<string, string>> | undefined): Map<string, HelperFn>;
119
+ export {};
120
+ //# sourceMappingURL=InkerProvider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InkerProvider.d.ts","sourceRoot":"","sources":["../src/InkerProvider.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,448 @@
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
+ import { AsyncLocalStorage } from "node:async_hooks";
22
+ import * as fs from "node:fs";
23
+ import { isAbsolute, resolve as resolvePath } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { InkerRenderer } from "./InkerRenderer.js";
26
+ import { SafeString } from "./SafeString.js";
27
+ import { setInker } from "./services/main.js";
28
+ import { Templates } from "./Templates.js";
29
+ // ─── Module-scoped flags (process-level, not instance-level) ─────────
30
+ let peerWarnEmitted = false;
31
+ let appRootFallbackWarned = false;
32
+ const overrideWarnEmittedNames = new Set();
33
+ /** @internal Reset module-level flags between tests. */
34
+ export function resetInkerProviderFlags() {
35
+ peerWarnEmitted = false;
36
+ appRootFallbackWarned = false;
37
+ overrideWarnEmittedNames.clear();
38
+ }
39
+ // ─── Provider class ──────────────────────────────────────────────
40
+ export default class InkerProvider {
41
+ app;
42
+ #als;
43
+ #renderer;
44
+ #started = false;
45
+ // P17: per-instance override-warn dedup. Was a module-level Set shared
46
+ // across every provider instance in the process — broke test isolation
47
+ // and multi-tenant scenarios where each tenant has its own provider with
48
+ // its own additionalHelpers map.
49
+ #overrideWarnedNames = new Set();
50
+ constructor(app) {
51
+ this.app = app;
52
+ }
53
+ register() {
54
+ this.app.container.singleton(InkerRenderer, () => this.#getRendererOrThrow());
55
+ this.app.container.singleton("inker", () => this.app.container.resolve(InkerRenderer));
56
+ }
57
+ async boot() {
58
+ // No-op. Peers (Rosetta, Router) are resolved at start() — earlier
59
+ // phases run before Ignitor finishes wiring the router proxy and
60
+ // before RosettaProvider's boot loads catalogs.
61
+ }
62
+ async start() {
63
+ if (this.#started)
64
+ return;
65
+ // Phase 1 — lazy peer imports. Both `@c9up/ream/services/router` and
66
+ // `@c9up/rosetta` are optional peers. Module-not-found is the
67
+ // degraded-host signal: silently return + warn-once. Anything else
68
+ // re-throws.
69
+ let router;
70
+ let rosetta;
71
+ try {
72
+ // Variable specifier so tsc does not statically resolve the optional
73
+ // `@c9up/ream` peer at build time (keeps inker standalone-buildable).
74
+ const routerSpecifier = "@c9up/ream/services/router";
75
+ const routerMod = await import(routerSpecifier);
76
+ router = routerMod.default;
77
+ const rosettaContainer = this.#resolveRosetta();
78
+ if (rosettaContainer === undefined) {
79
+ this.#warnPeerMissingOnce("`@c9up/rosetta` is available as a module but no Rosetta instance is registered in the container. The `t()` helper will throw at first render.");
80
+ return;
81
+ }
82
+ rosetta = rosettaContainer;
83
+ }
84
+ catch (err) {
85
+ if (isModuleNotFound(err)) {
86
+ this.#warnPeerMissingOnce("`@c9up/ream/services/router` or `@c9up/rosetta` is not installed. Inker rendering is disabled until both peers are present.");
87
+ return;
88
+ }
89
+ throw err;
90
+ }
91
+ // Phase 2 — resolve config.
92
+ const config = this.app.config.get("inker") ?? {};
93
+ const appRoot = this.#readAppRoot();
94
+ const templatesRoot = resolveTemplatesRoot(config.templatesRoot, appRoot);
95
+ const cacheMode = resolveCacheMode(config.cacheMode);
96
+ const assetManifest = loadAssetManifest(config.assetManifest, appRoot);
97
+ // Phase 3 — build canonical helpers Map.
98
+ const als = new AsyncLocalStorage();
99
+ this.#als = als;
100
+ const canonical = buildCanonicalHelpers(als, rosetta, router, assetManifest);
101
+ // Phase 4 — merge additional helpers (override-warn-once per instance).
102
+ const merged = mergeHelpers(canonical, config.additionalHelpers, this.#overrideWarnedNames);
103
+ // Phase 5 — construct Templates + InkerRenderer + bind into proxy.
104
+ const templates = new Templates({
105
+ root: templatesRoot,
106
+ cacheMode,
107
+ helpers: merged,
108
+ });
109
+ const renderer = new InkerRenderer(templates, als);
110
+ this.#renderer = renderer;
111
+ setInker(renderer);
112
+ this.#started = true;
113
+ }
114
+ async ready() { }
115
+ async shutdown() {
116
+ // Intentionally a no-op. `#started` guards `start()` from re-running,
117
+ // so once the provider has booted, subsequent lifecycle calls have
118
+ // nothing to undo here: `Templates` owns its own cache, AsyncLocalStorage
119
+ // has no destroy contract, and the `setInker` singleton intentionally
120
+ // outlives shutdown so late-arriving handlers don't see a torn-down
121
+ // proxy. `Templates.clearCache()` is the operator's tool, not ours.
122
+ }
123
+ #getRendererOrThrow() {
124
+ if (this.#renderer === undefined) {
125
+ throw new Error("[inker] InkerRenderer resolved before InkerProvider.start() ran. " +
126
+ "Wait for the boot lifecycle to complete, or call `start()` manually.");
127
+ }
128
+ return this.#renderer;
129
+ }
130
+ #warnPeerMissingOnce(detail) {
131
+ if (peerWarnEmitted)
132
+ return;
133
+ peerWarnEmitted = true;
134
+ console.warn(`[inker] ${detail} See https://ream.dev/modules/inker.`);
135
+ }
136
+ #readAppRoot() {
137
+ try {
138
+ const raw = this.app.container.resolve("appRoot");
139
+ if (raw instanceof URL)
140
+ return fileURLToPath(raw);
141
+ if (typeof raw === "string")
142
+ return raw;
143
+ }
144
+ catch (err) {
145
+ // Only swallow the "no binding" path — re-throw factory errors so
146
+ // host misconfiguration surfaces instead of being masked as a
147
+ // cwd-fallback.
148
+ if (!isContainerNotFound(err))
149
+ throw err;
150
+ }
151
+ if (!appRootFallbackWarned) {
152
+ appRootFallbackWarned = true;
153
+ console.warn("[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.");
154
+ }
155
+ return process.cwd();
156
+ }
157
+ #resolveRosetta() {
158
+ // Try container resolution under both the canonical "rosetta" alias
159
+ // and the class binding. RosettaProvider binds both (per
160
+ // `packages/rosetta/src/RosettaProvider.ts`).
161
+ //
162
+ // Only the "binding not registered" path is swallowed (host truly
163
+ // lacks Rosetta — Phase 1 silently degrades). Factory-thrown errors
164
+ // (catalog load failure, malformed YAML, etc.) re-throw — Station's
165
+ // `#resolveDb` is loud for the same reason: surfacing operator
166
+ // misconfiguration beats misdiagnosing it as "rosetta missing".
167
+ const tokens = ["rosetta", "Rosetta"];
168
+ for (const token of tokens) {
169
+ try {
170
+ const candidate = this.app.container.resolve(token);
171
+ if (isRosettaShape(candidate)) {
172
+ return candidate;
173
+ }
174
+ }
175
+ catch (err) {
176
+ if (isContainerNotFound(err))
177
+ continue;
178
+ throw err;
179
+ }
180
+ }
181
+ return undefined;
182
+ }
183
+ }
184
+ // ─── Pure resolvers (exported @internal for unit tests) ──────────────
185
+ /**
186
+ * Resolve the templates root directory:
187
+ * - missing / empty → `<appRoot>/resources/templates`
188
+ * - absolute path → pass through
189
+ * - relative path → joined to `appRoot`
190
+ */
191
+ export function resolveTemplatesRoot(userPath, appRoot) {
192
+ if (typeof userPath !== "string" || userPath.length === 0) {
193
+ return resolvePath(appRoot, "resources/templates");
194
+ }
195
+ return isAbsolute(userPath) ? userPath : resolvePath(appRoot, userPath);
196
+ }
197
+ /**
198
+ * Resolve the cache mode:
199
+ * - explicit "mtime" / "never" → pass through
200
+ * - "auto" / undefined → "never" in production, "mtime" otherwise
201
+ * - anything else → throw (typo'd modes like `"Production"` or `"NEVER"`
202
+ * should not silently downgrade to dev caching)
203
+ */
204
+ export function resolveCacheMode(userMode) {
205
+ if (userMode === "mtime" || userMode === "never")
206
+ return userMode;
207
+ if (userMode !== undefined && userMode !== "auto") {
208
+ throw new Error(`[inker] config.inker.cacheMode must be "mtime", "never", "auto", or undefined; got ${JSON.stringify(userMode)}.`);
209
+ }
210
+ return process.env.NODE_ENV === "production" ? "never" : "mtime";
211
+ }
212
+ /**
213
+ * Load the asset manifest:
214
+ * - injected value wins (returned verbatim — the caller's freezing applies)
215
+ * - else read `<appRoot>/public/manifest.json` synchronously at boot
216
+ * - else `undefined`
217
+ *
218
+ * Malformed manifests (non-object root, array, JSON parse error) → `undefined`.
219
+ * Non-string entries inside a valid object are silently dropped (D8).
220
+ */
221
+ export function loadAssetManifest(injected, appRoot) {
222
+ if (injected !== undefined)
223
+ return injected;
224
+ const manifestPath = resolvePath(appRoot, "public/manifest.json");
225
+ let raw;
226
+ try {
227
+ raw = fs.readFileSync(manifestPath, "utf8");
228
+ }
229
+ catch (err) {
230
+ // P19: ENOENT is "no manifest configured" — silent absence is the
231
+ // expected dev-without-build state. Any OTHER error (EACCES, EISDIR,
232
+ // ELOOP, etc.) indicates a real misconfiguration that would otherwise
233
+ // surface as a silent "every asset URL falls back to /_assets/foo"
234
+ // degradation in prod. Warn so the operator sees the misconfig.
235
+ const code = err instanceof Error ? Reflect.get(err, "code") : undefined;
236
+ if (typeof code === "string" && code !== "ENOENT") {
237
+ console.warn(`[inker] Failed to read asset manifest at ${manifestPath}: ${code}. asset() helpers will fall back to '/_assets/<path>' until this is resolved.`);
238
+ }
239
+ return undefined;
240
+ }
241
+ let parsed;
242
+ try {
243
+ parsed = JSON.parse(raw);
244
+ }
245
+ catch {
246
+ return undefined;
247
+ }
248
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
249
+ return undefined;
250
+ }
251
+ const out = Object.create(null);
252
+ for (const [k, v] of Object.entries(parsed)) {
253
+ if (typeof v === "string")
254
+ out[k] = v;
255
+ }
256
+ return Object.freeze(out);
257
+ }
258
+ /**
259
+ * Merge canonical + app-supplied helpers into one Map. Override warns once
260
+ * per name per process. Function-type validation is local; helper-key
261
+ * validation (identifier shape / reserved words / prototype-pollution
262
+ * denylists) is delegated to the `Templates` constructor (53.4 AC1).
263
+ */
264
+ export function mergeHelpers(canonical, additional,
265
+ // P17: optional per-instance warn-dedup set. Defaults to the module-level
266
+ // set for backward compat with direct callers; InkerProvider now passes
267
+ // its own per-instance `#overrideWarnedNames` so multi-tenant /
268
+ // multi-provider setups don't share warn state. Tests that rely on the
269
+ // module-level set still work via `resetInkerProviderFlags`.
270
+ warnedNames = overrideWarnEmittedNames) {
271
+ const out = new Map(canonical);
272
+ if (additional === undefined)
273
+ return out;
274
+ for (const [name, fn] of Object.entries(additional)) {
275
+ if (typeof fn !== "function") {
276
+ throw new Error(`[inker] additionalHelpers.${name} must be a function; got ${typeof fn}.`);
277
+ }
278
+ if (out.has(name) && !warnedNames.has(name)) {
279
+ warnedNames.add(name);
280
+ console.warn(`[inker] additionalHelpers.${name} overrides the canonical helper. Suppressing further warnings for this name.`);
281
+ }
282
+ out.set(name, fn);
283
+ }
284
+ return out;
285
+ }
286
+ /**
287
+ * Coerce `url()` params: every value becomes a string via `String(v)`. Nullish
288
+ * roots return `undefined` (no replacement map needed). Non-object roots and
289
+ * arrays throw. Null / undefined / Symbol values throw rather than emit
290
+ * silently-broken URLs like `/users/undefined`.
291
+ */
292
+ export function coerceUrlParams(raw) {
293
+ if (raw === undefined || raw === null)
294
+ return undefined;
295
+ if (typeof raw !== "object" || Array.isArray(raw)) {
296
+ throw new Error(`[inker] url() params must be a plain object; got ${Array.isArray(raw) ? "array" : typeof raw}.`);
297
+ }
298
+ // P9: Date objects pass the "is object, not array" check but `Object.entries`
299
+ // returns `[]` for them — silently emitting an empty params Map and a URL
300
+ // built from no replacements. Refuse explicitly with a hint pointing to
301
+ // `toISOString()`.
302
+ if (raw instanceof Date) {
303
+ throw new Error("[inker] url() params cannot be a Date instance — call `.toISOString()` first or wrap it in a plain object.");
304
+ }
305
+ const out = Object.create(null);
306
+ for (const [k, v] of Object.entries(raw)) {
307
+ if (v === null || v === undefined) {
308
+ throw new Error(`[inker] url() param '${k}' is ${v === null ? "null" : "undefined"} — omit the key or provide a value.`);
309
+ }
310
+ if (typeof v === "symbol") {
311
+ throw new Error(`[inker] url() param '${k}' is a Symbol — only stringifiable primitives are supported.`);
312
+ }
313
+ // P8: NaN / +Infinity / -Infinity all stringify into URL-unfriendly
314
+ // `"NaN"` / `"Infinity"` literals, producing routes like
315
+ // `/users/NaN`. Authors usually arrive here via a downstream helper
316
+ // that returned an unexpected non-finite value; surface it loud.
317
+ if (typeof v === "number" && !Number.isFinite(v)) {
318
+ throw new Error(`[inker] url() param '${k}' is ${Number.isNaN(v) ? "NaN" : v > 0 ? "Infinity" : "-Infinity"} — only finite numbers are supported.`);
319
+ }
320
+ out[k] = String(v);
321
+ }
322
+ return out;
323
+ }
324
+ /**
325
+ * 5-char HTML attribute-value escaper. Distinct from `escapeHtml` (text-node
326
+ * use): attribute values need BOTH `"` and `'` escape so `value="…"` and
327
+ * `value='…'` cannot be broken, while text-nodes don't need quote escapes
328
+ * but do need `&` first to avoid double-escape.
329
+ */
330
+ export function escapeAttr(value) {
331
+ // P10: backtick added for parity with `escapeChar` in render.ts. Legacy
332
+ // IE and some permissive parsers treat backtick as an attribute-value
333
+ // delimiter inside unquoted attributes; we still emit quoted attributes
334
+ // but encode it defensively in case a downstream rewrite drops the
335
+ // quotes.
336
+ return value
337
+ .replace(/&/g, "&amp;")
338
+ .replace(/</g, "&lt;")
339
+ .replace(/>/g, "&gt;")
340
+ .replace(/"/g, "&quot;")
341
+ .replace(/'/g, "&#39;")
342
+ .replace(/`/g, "&#96;");
343
+ }
344
+ /**
345
+ * Build the four canonical helper bodies. Each closes over `als` + its
346
+ * resolved peer + the (frozen) asset manifest. Helpers are SYNC — crossing
347
+ * an async boundary would drop the ALS frame (53.4 D2).
348
+ */
349
+ export function buildCanonicalHelpers(als, rosetta, router, assetManifest) {
350
+ const requireCtx = (helperName) => {
351
+ const ctx = als.getStore();
352
+ if (ctx === undefined) {
353
+ throw new Error(`[inker] ${helperName}() invoked outside of an inker.render(ctx, …) call — store unavailable.`);
354
+ }
355
+ return ctx;
356
+ };
357
+ const helpers = new Map();
358
+ helpers.set("t", (...args) => {
359
+ const [key, params] = args;
360
+ if (typeof key !== "string") {
361
+ throw new Error(`[inker] t() requires a string key; got ${typeof key}.`);
362
+ }
363
+ const ctx = requireCtx("t");
364
+ // Rosetta's TranslationParams is narrower than HelperFn's
365
+ // `unknown[]` — the load-bearing narrow is the contract boundary;
366
+ // Rosetta validates value types and throws on unsupported shapes.
367
+ const rosettaParams = params === undefined
368
+ ? undefined
369
+ : loadBearingCast(params);
370
+ return rosetta.t(key, rosettaParams, { locale: ctx.locale });
371
+ });
372
+ helpers.set("csrfField", (..._args) => {
373
+ const ctx = requireCtx("csrfField");
374
+ const token = ctx.store.get("csrfToken");
375
+ if (typeof token !== "string" || token.length === 0) {
376
+ throw new Error("[inker] csrfField() requires the @c9up/blackhole middleware with csrf enabled (csrfToken not found in ctx.store).");
377
+ }
378
+ return new SafeString(`<input type="hidden" name="_csrf" value="${escapeAttr(token)}">`);
379
+ });
380
+ helpers.set("csrfMeta", (..._args) => {
381
+ const ctx = requireCtx("csrfMeta");
382
+ const token = ctx.store.get("csrfToken");
383
+ if (typeof token !== "string" || token.length === 0) {
384
+ throw new Error("[inker] csrfMeta() requires the @c9up/blackhole middleware with csrf enabled (csrfToken not found in ctx.store).");
385
+ }
386
+ return new SafeString(`<meta name="csrf-token" content="${escapeAttr(token)}">`);
387
+ });
388
+ helpers.set("cspNonce", (..._args) => {
389
+ const ctx = requireCtx("cspNonce");
390
+ const nonce = ctx.store.get("cspNonce");
391
+ // Non-throwing: CSP nonces are opt-in (only present when the CSP uses
392
+ // `@nonce`), so an absent nonce yields an empty attribute, not an error.
393
+ return typeof nonce === "string" ? nonce : "";
394
+ });
395
+ helpers.set("url", (...args) => {
396
+ const [name, params] = args;
397
+ if (typeof name !== "string") {
398
+ throw new Error(`[inker] url() requires a string route name; got ${typeof name}.`);
399
+ }
400
+ const coerced = coerceUrlParams(params);
401
+ return router.makeUrl(name, coerced);
402
+ });
403
+ helpers.set("asset", (...args) => {
404
+ const [name] = args;
405
+ if (typeof name !== "string") {
406
+ throw new Error(`[inker] asset() requires a string asset name; got ${typeof name}.`);
407
+ }
408
+ return assetManifest?.[name] ?? `/_assets/${name}`;
409
+ });
410
+ return helpers;
411
+ }
412
+ // ─── Internal predicates / casts ──────────────────────────────────
413
+ function isRosettaShape(value) {
414
+ return (value !== null &&
415
+ typeof value === "object" &&
416
+ typeof Reflect.get(value, "t") === "function");
417
+ }
418
+ /** Node's ERR_MODULE_NOT_FOUND surfaces on an Error subclass with `code`. */
419
+ function isModuleNotFound(err) {
420
+ if (err === null || typeof err !== "object" || !("code" in err))
421
+ return false;
422
+ const { code } = err;
423
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
424
+ }
425
+ /**
426
+ * Ream's container throws a `ReamError` with `code === "CONTAINER_NOT_FOUND"`
427
+ * when a token is unbound. Duck-typed here so `@c9up/ream` stays an optional
428
+ * peer (no import-time dep on its error class).
429
+ */
430
+ function isContainerNotFound(err) {
431
+ if (err === null || typeof err !== "object" || !("code" in err))
432
+ return false;
433
+ return err.code === "CONTAINER_NOT_FOUND";
434
+ }
435
+ /**
436
+ * SANCTIONED CROSS-PACKAGE NARROWING — the ONE production site in
437
+ * `@c9up/inker/provider` where `as T` is permitted. Memory
438
+ * `feedback_no_any_types` is honoured by funnelling every load-bearing
439
+ * narrow (dynamic peer imports, Rosetta params widened to Inker's HelperFn
440
+ * shape) through this single function. Analogous to 54.2 AC15 / 54.1 AC9 /
441
+ * `tests/__helpers__/bypass-type-check.ts`. Every call site MUST carry a
442
+ * rationale comment explaining why static narrowing isn't expressible at
443
+ * the boundary. NEVER widen this helper beyond `unknown → T`.
444
+ */
445
+ function loadBearingCast(value) {
446
+ return value;
447
+ }
448
+ //# sourceMappingURL=InkerProvider.js.map