@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.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/InkerProvider.d.ts +120 -0
- package/dist/InkerProvider.d.ts.map +1 -0
- package/dist/InkerProvider.js +448 -0
- package/dist/InkerProvider.js.map +1 -0
- package/dist/InkerRenderError.d.ts +16 -0
- package/dist/InkerRenderError.d.ts.map +1 -0
- package/dist/InkerRenderError.js +11 -0
- package/dist/InkerRenderError.js.map +1 -0
- package/dist/InkerRenderer.d.ts +28 -0
- package/dist/InkerRenderer.d.ts.map +1 -0
- package/dist/InkerRenderer.js +21 -0
- package/dist/InkerRenderer.js.map +1 -0
- package/dist/SafeString.d.ts +15 -0
- package/dist/SafeString.d.ts.map +1 -0
- package/dist/SafeString.js +24 -0
- package/dist/SafeString.js.map +1 -0
- package/dist/Templates.d.ts +16 -0
- package/dist/Templates.d.ts.map +1 -0
- package/dist/Templates.js +908 -0
- package/dist/Templates.js.map +1 -0
- package/dist/helpers.d.ts +50 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +2 -0
- package/dist/helpers.js.map +1 -0
- package/dist/identifierGuards.d.ts +24 -0
- package/dist/identifierGuards.d.ts.map +1 -0
- package/dist/identifierGuards.js +49 -0
- package/dist/identifierGuards.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/loadNapi.d.ts +69 -0
- package/dist/loadNapi.d.ts.map +1 -0
- package/dist/loadNapi.js +145 -0
- package/dist/loadNapi.js.map +1 -0
- package/dist/services/main.d.ts +23 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +49 -0
- package/dist/services/main.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +64 -0
- package/scripts/copy-napi.mjs +62 -0
- package/src/InkerProvider.ts +594 -0
- package/src/InkerRenderError.ts +49 -0
- package/src/InkerRenderer.ts +55 -0
- package/src/SafeString.ts +27 -0
- package/src/Templates.ts +1324 -0
- package/src/helpers.ts +57 -0
- package/src/identifierGuards.ts +49 -0
- package/src/index.ts +14 -0
- package/src/loadNapi.ts +270 -0
- 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, "&")
|
|
338
|
+
.replace(/</g, "<")
|
|
339
|
+
.replace(/>/g, ">")
|
|
340
|
+
.replace(/"/g, """)
|
|
341
|
+
.replace(/'/g, "'")
|
|
342
|
+
.replace(/`/g, "`");
|
|
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
|