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