@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/src/helpers.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { SafeString } from "./SafeString.js";
2
+
3
+ /**
4
+ * A caller-registered template helper. Receives the in-scope-evaluated
5
+ * argument values and returns a string (HTML-escaped by the renderer) or a
6
+ * `SafeString` (emitted verbatim). Re-homed here from the deleted `render.ts`
7
+ * when the lex/parse/render hot path moved to the Rust engine (Story 55.1).
8
+ *
9
+ * NOTE (55.1): argument values are evaluated in the Rust engine and cross the
10
+ * NAPI boundary as JSON, so they are JSON-coerced before reaching the helper:
11
+ * a `Date` arrives as a string, a `bigint` as a (possibly lossy) number, and
12
+ * `NaN` / `±Infinity` as `null`. Pass pre-stringified values for any type that
13
+ * does not survive JSON if the helper needs the original form.
14
+ */
15
+ export type HelperFn = (...args: readonly unknown[]) => string | SafeString;
16
+
17
+ /**
18
+ * Signature for the canonical `t(key, params?)` helper.
19
+ *
20
+ * Inker ships ZERO body for this helper — implementations come from
21
+ * the caller via `TemplatesOptions.helpers`. The Ream provider (Story
22
+ * 53.5) wires this signature to `@c9up/rosetta`'s `Rosetta#t(key,
23
+ * params?, options?)`.
24
+ */
25
+ export type THelper = (key: string, params?: Record<string, unknown>) => string;
26
+
27
+ /**
28
+ * Signature for the canonical `csrfField()` helper.
29
+ *
30
+ * Returns a `SafeString` containing the `<input type="hidden"
31
+ * name="_csrf" value="…">` element. Returning a `SafeString` is
32
+ * mandatory — a plain string would be HTML-escaped by the renderer
33
+ * and the resulting `&lt;input …&gt;` would not be a usable form
34
+ * field. Implementation is provided by the Ream provider (Story 53.5)
35
+ * wiring the session's CSRF token.
36
+ */
37
+ export type CsrfFieldHelper = () => SafeString;
38
+
39
+ /**
40
+ * Signature for the canonical `url(name, params?)` helper.
41
+ *
42
+ * Resolves a named route + interpolates path params. Implementation
43
+ * is provided by the Ream provider (Story 53.5) wiring the router.
44
+ */
45
+ export type UrlHelper = (
46
+ name: string,
47
+ params?: Record<string, unknown>,
48
+ ) => string;
49
+
50
+ /**
51
+ * Signature for the canonical `asset(name)` helper.
52
+ *
53
+ * Resolves an asset manifest entry to its hashed public URL.
54
+ * Implementation is provided by the Ream provider (Story 53.5) wiring
55
+ * the asset manifest.
56
+ */
57
+ export type AssetHelper = (name: string) => string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Identifier denylists shared between `parseBlockTag.ts` (each-binding +
3
+ * component-arg-key validation) and `parseExpression.ts` (object-literal
4
+ * key validation).
5
+ *
6
+ * `PROTOTYPE_POLLUTION_KEYS` blocks the three keys whose own-property
7
+ * assignment can shadow `Object.prototype` methods or invoke
8
+ * `Object.create` semantics in surprising ways. The gate is
9
+ * defence-in-depth: JavaScript's `obj[key] = value` semantics make
10
+ * `__proto__` non-exploitable when used via square brackets on a
11
+ * non-frozen object built via `Object.create(null)`, but template
12
+ * authors cannot reason about the renderer's storage shape and the
13
+ * cost of the guard is one Set membership check per object key.
14
+ *
15
+ * `RESERVED_BINDING_NAMES` blocks identifiers that would collide with
16
+ * Inker grammar keywords (e.g. `as`) or JavaScript reserved words when
17
+ * used as `{% each items as <name> %}` bindings or `{% each items as
18
+ * [<a>, <b>] %}` destructured names. The block is per-position rather
19
+ * than universal — Inker permits these names inside path expressions
20
+ * since paths cannot collide with the grammar.
21
+ */
22
+ export const PROTOTYPE_POLLUTION_KEYS: ReadonlySet<string> = new Set([
23
+ "__proto__",
24
+ "constructor",
25
+ "prototype",
26
+ ]);
27
+
28
+ export const RESERVED_BINDING_NAMES: ReadonlySet<string> = new Set([
29
+ "as",
30
+ "if",
31
+ "else",
32
+ "each",
33
+ "do",
34
+ "for",
35
+ "while",
36
+ "let",
37
+ "const",
38
+ "var",
39
+ "return",
40
+ "function",
41
+ "class",
42
+ "new",
43
+ "this",
44
+ "super",
45
+ "null",
46
+ "undefined",
47
+ "true",
48
+ "false",
49
+ ]);
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type {
2
+ AssetHelper,
3
+ CsrfFieldHelper,
4
+ HelperFn,
5
+ THelper,
6
+ UrlHelper,
7
+ } from "./helpers.js";
8
+ export {
9
+ type InkerErrorCode,
10
+ type InkerErrorContext,
11
+ InkerRenderError,
12
+ } from "./InkerRenderError.js";
13
+ export { SafeString } from "./SafeString.js";
14
+ export { Templates, type TemplatesOptions } from "./Templates.js";
@@ -0,0 +1,270 @@
1
+ // PATTERN: copy-and-rename for 55.2/55.3/55.4 — Rust hot-path packages.
2
+ //
3
+ // Loads the native `inker-engine-napi` binary built by `scripts/copy-napi.mjs`
4
+ // and re-throws load failures as `E_INKER_NAPI_REQUIRED` per cerebrum
5
+ // NAPI-loader pattern (2026-04-27) — actionable hint points at
6
+ // `pnpm --filter @c9up/inker build:napi`.
7
+ //
8
+ // Per cerebrum 2026-04-15 there is NO JS fallback. If the binary fails to
9
+ // load, consumers get a typed error. Zero `as` / `any` per cerebrum 2026-05-04.
10
+
11
+ import { createRequire } from "node:module";
12
+ import { arch, platform } from "node:process";
13
+ import { fileURLToPath } from "node:url";
14
+ import { type InkerErrorCode, InkerRenderError } from "./InkerRenderError.js";
15
+
16
+ const SUFFIX_MAP: Readonly<Record<string, string>> = {
17
+ "linux-x64": "linux-x64-gnu",
18
+ "linux-arm64": "linux-arm64-gnu",
19
+ "darwin-x64": "darwin-x64",
20
+ "darwin-arm64": "darwin-arm64",
21
+ "win32-x64": "win32-x64-msvc",
22
+ };
23
+
24
+ function platformSuffix(): string {
25
+ const key = `${platform}-${arch}`;
26
+ const suffix = SUFFIX_MAP[key];
27
+ if (typeof suffix !== "string") {
28
+ throw new InkerRenderError(
29
+ "E_INKER_NAPI_REQUIRED",
30
+ `Unsupported platform/arch '${key}' for @c9up/inker native binary. Supported: ${Object.keys(SUFFIX_MAP).join(", ")}.`,
31
+ );
32
+ }
33
+ return suffix;
34
+ }
35
+
36
+ /** A `{% include %}` / `{% component %}` reference with source position. */
37
+ export interface NapiNodeRef {
38
+ readonly name: string;
39
+ readonly line: number;
40
+ readonly column: number;
41
+ }
42
+
43
+ /** A `{{> name }}` slot reference. */
44
+ export interface NapiSlotRef {
45
+ readonly name: string;
46
+ readonly line: number;
47
+ readonly column: number;
48
+ }
49
+
50
+ /** First disk-requiring node (`renderString` E_INKER_DISK_REQUIRED guard). */
51
+ export interface NapiDiskNodeRef {
52
+ readonly kind: string;
53
+ readonly name: string;
54
+ }
55
+
56
+ /** All composition metadata for one parsed AST (one NAPI call). */
57
+ export interface NapiComposeInfo {
58
+ readonly hasLayout: boolean;
59
+ readonly layoutName: string | null;
60
+ readonly layoutLine: number | null;
61
+ readonly layoutColumn: number | null;
62
+ readonly slots: readonly NapiSlotRef[];
63
+ readonly partials: readonly NapiNodeRef[];
64
+ readonly components: readonly NapiNodeRef[];
65
+ readonly hasContent: boolean;
66
+ readonly firstDiskNode: NapiDiskNodeRef | null;
67
+ }
68
+
69
+ /** Opaque handle to a parsed Rust AST. */
70
+ export interface NapiInkerAst {
71
+ readonly composeInfo: NapiComposeInfo;
72
+ }
73
+
74
+ /** One in-scope-evaluated helper invocation request (collect pass). */
75
+ export interface NapiInvocation {
76
+ readonly id: number;
77
+ readonly name: string;
78
+ /** JSON array of the evaluated argument values. */
79
+ readonly args: readonly unknown[];
80
+ }
81
+
82
+ /** One pre-resolved helper result (consumed in tape order by the renderer). */
83
+ export interface NapiHelperResult {
84
+ readonly value: string;
85
+ readonly isSafe: boolean;
86
+ }
87
+
88
+ export interface NapiRenderContext {
89
+ readonly partials: Record<string, NapiInkerAst>;
90
+ readonly components: Record<string, NapiInkerAst>;
91
+ readonly bodyHtml: string | undefined;
92
+ readonly templateName: string | undefined;
93
+ readonly templatePath: string | undefined;
94
+ }
95
+
96
+ interface NativeExports {
97
+ readonly engineVersion: () => string;
98
+ readonly parseTemplate: (
99
+ source: string,
100
+ helpers: readonly string[],
101
+ ) => NapiInkerAst;
102
+ readonly collectInvocations: (
103
+ ast: NapiInkerAst,
104
+ data: unknown,
105
+ ctx: NapiRenderContext,
106
+ ) => NapiInvocation[];
107
+ readonly renderAst: (
108
+ ast: NapiInkerAst,
109
+ data: unknown,
110
+ resolved: readonly NapiHelperResult[],
111
+ ctx: NapiRenderContext,
112
+ ) => string;
113
+ }
114
+
115
+ function isNativeExports(value: unknown): value is NativeExports {
116
+ if (value === null || typeof value !== "object") return false;
117
+ return (
118
+ typeof Reflect.get(value, "engineVersion") === "function" &&
119
+ typeof Reflect.get(value, "parseTemplate") === "function" &&
120
+ typeof Reflect.get(value, "collectInvocations") === "function" &&
121
+ typeof Reflect.get(value, "renderAst") === "function"
122
+ );
123
+ }
124
+
125
+ let cachedNative: NativeExports | undefined;
126
+
127
+ export function getNative(): NativeExports {
128
+ if (cachedNative !== undefined) return cachedNative;
129
+
130
+ const require = createRequire(import.meta.url);
131
+ const here = fileURLToPath(import.meta.url);
132
+ // `here` is `…/packages/inker/{src,dist}/loadNapi.ts|js`. The `.node` lives
133
+ // one level up at `…/packages/inker/index.<suffix>.node`. `..` traversal
134
+ // resolved via require (handles both dev `src/` and publish `dist/`).
135
+ const suffix = platformSuffix();
136
+ const candidates: readonly string[] = [`../index.${suffix}.node`];
137
+ let loaded: unknown;
138
+ let lastErr: unknown;
139
+ for (const candidate of candidates) {
140
+ try {
141
+ loaded = require(candidate);
142
+ break;
143
+ } catch (err) {
144
+ lastErr = err;
145
+ }
146
+ }
147
+ if (loaded === undefined) {
148
+ const causeMessage =
149
+ lastErr instanceof Error ? lastErr.message : String(lastErr);
150
+ // The prebuilt linux binaries target glibc (`-gnu`). On musl hosts (Alpine
151
+ // containers) the `-gnu` binary fails to dlopen with a libc symbol error —
152
+ // surface that explicitly rather than only pointing at the build step.
153
+ const muslHint = suffix.endsWith("-gnu")
154
+ ? " If you are on Alpine/musl, note the prebuilt binaries target glibc (musl is not a supported target)."
155
+ : "";
156
+ throw new InkerRenderError(
157
+ "E_INKER_NAPI_REQUIRED",
158
+ `@c9up/inker native binary 'index.${suffix}.node' not found or failed to load near ${here} — run 'pnpm --filter @c9up/inker build:napi' to build it.${muslHint} Cause: ${causeMessage}`,
159
+ undefined,
160
+ { cause: lastErr },
161
+ );
162
+ }
163
+ if (!isNativeExports(loaded)) {
164
+ throw new InkerRenderError(
165
+ "E_INKER_NAPI_REQUIRED",
166
+ `@c9up/inker native binary loaded but missing expected exports (engineVersion / parseTemplate / renderAst). Rebuild with 'pnpm --filter @c9up/inker build:napi'.`,
167
+ );
168
+ }
169
+ cachedNative = loaded;
170
+ return cachedNative;
171
+ }
172
+
173
+ /**
174
+ * Shape of the JSON payload Rust packs into `napi::Error::from_reason`. Rust
175
+ * guarantees `code` / `message` present; positional fields optional.
176
+ */
177
+ interface NapiErrorPayload {
178
+ readonly code: string;
179
+ readonly message: string;
180
+ readonly line?: number;
181
+ readonly column?: number;
182
+ readonly templateName?: string;
183
+ }
184
+
185
+ function readString(target: unknown, key: string): string | undefined {
186
+ const v = Reflect.get(Object(target), key);
187
+ return typeof v === "string" ? v : undefined;
188
+ }
189
+
190
+ function readNumber(target: unknown, key: string): number | undefined {
191
+ const v = Reflect.get(Object(target), key);
192
+ return typeof v === "number" ? v : undefined;
193
+ }
194
+
195
+ function isNapiErrorPayload(value: unknown): value is NapiErrorPayload {
196
+ if (value === null || typeof value !== "object") return false;
197
+ return (
198
+ typeof Reflect.get(value, "code") === "string" &&
199
+ typeof Reflect.get(value, "message") === "string"
200
+ );
201
+ }
202
+
203
+ const CODE_MAP: Readonly<Record<string, InkerErrorCode>> = {
204
+ E_INKER_TEMPLATE_NOT_FOUND: "E_INKER_TEMPLATE_NOT_FOUND",
205
+ E_INKER_PARSE_ERROR: "E_INKER_PARSE_ERROR",
206
+ E_INKER_UNKNOWN_IDENTIFIER: "E_INKER_UNKNOWN_IDENTIFIER",
207
+ E_INKER_INVALID_PATH: "E_INKER_INVALID_PATH",
208
+ E_INKER_UNCLOSED_INTERPOLATION: "E_INKER_UNCLOSED_INTERPOLATION",
209
+ E_INKER_UNCLOSED_BLOCK_TAG: "E_INKER_UNCLOSED_BLOCK_TAG",
210
+ E_INKER_UNKNOWN_DIRECTIVE: "E_INKER_UNKNOWN_DIRECTIVE",
211
+ E_INKER_INVALID_LAYOUT_POSITION: "E_INKER_INVALID_LAYOUT_POSITION",
212
+ E_INKER_DUPLICATE_LAYOUT: "E_INKER_DUPLICATE_LAYOUT",
213
+ E_INKER_NESTED_LAYOUT_UNSUPPORTED: "E_INKER_NESTED_LAYOUT_UNSUPPORTED",
214
+ E_INKER_LAYOUT_IN_PARTIAL: "E_INKER_LAYOUT_IN_PARTIAL",
215
+ E_INKER_CIRCULAR_INCLUDE: "E_INKER_CIRCULAR_INCLUDE",
216
+ E_INKER_MISSING_SLOT: "E_INKER_MISSING_SLOT",
217
+ E_INKER_UNKNOWN_SLOT: "E_INKER_UNKNOWN_SLOT",
218
+ E_INKER_DISK_REQUIRED: "E_INKER_DISK_REQUIRED",
219
+ E_INKER_UNCLOSED_BLOCK: "E_INKER_UNCLOSED_BLOCK",
220
+ E_INKER_UNMATCHED_BLOCK_END: "E_INKER_UNMATCHED_BLOCK_END",
221
+ E_INKER_MISMATCHED_BLOCK_END: "E_INKER_MISMATCHED_BLOCK_END",
222
+ E_INKER_INVALID_EXPRESSION: "E_INKER_INVALID_EXPRESSION",
223
+ E_INKER_INVALID_ITERABLE: "E_INKER_INVALID_ITERABLE",
224
+ E_INKER_UNKNOWN_HELPER: "E_INKER_UNKNOWN_HELPER",
225
+ E_INKER_HELPER_THROW: "E_INKER_HELPER_THROW",
226
+ E_INKER_NAPI_REQUIRED: "E_INKER_NAPI_REQUIRED",
227
+ };
228
+
229
+ /**
230
+ * Translate a thrown value from a NAPI call into an `InkerRenderError`.
231
+ * If it's already an `InkerRenderError` (helper threw and propagated), pass through.
232
+ * If it's a `napi::Error` carrying our JSON envelope, reconstruct typed.
233
+ * Otherwise wrap as parse error.
234
+ */
235
+ export function napiThrowToInker(err: unknown): InkerRenderError {
236
+ if (err instanceof InkerRenderError) return err;
237
+ if (err instanceof Error) {
238
+ let parsed: unknown;
239
+ try {
240
+ parsed = JSON.parse(err.message);
241
+ } catch {
242
+ parsed = undefined;
243
+ }
244
+ if (isNapiErrorPayload(parsed)) {
245
+ const code = CODE_MAP[parsed.code];
246
+ if (code !== undefined) {
247
+ return new InkerRenderError(
248
+ code,
249
+ parsed.message,
250
+ {
251
+ line: readNumber(parsed, "line"),
252
+ column: readNumber(parsed, "column"),
253
+ templateName: readString(parsed, "templateName"),
254
+ },
255
+ { cause: err },
256
+ );
257
+ }
258
+ }
259
+ return new InkerRenderError(
260
+ "E_INKER_PARSE_ERROR",
261
+ `Native call failed: ${err.message}`,
262
+ undefined,
263
+ { cause: err },
264
+ );
265
+ }
266
+ return new InkerRenderError(
267
+ "E_INKER_PARSE_ERROR",
268
+ `Native call failed with non-Error: ${String(err)}`,
269
+ );
270
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Default `InkerRenderer` singleton — Adonis-style:
3
+ *
4
+ * import inker from '@c9up/inker/provider/services/main'
5
+ *
6
+ * await inker.render(ctx, 'invoice', { user })
7
+ *
8
+ * Populated by `InkerProvider.start()` (registered via reamrc.ts) or by the
9
+ * app itself via `setInker(myRenderer)`.
10
+ */
11
+
12
+ import type { InkerRenderer } from "../InkerRenderer.js";
13
+
14
+ let instance: InkerRenderer | undefined;
15
+
16
+ /**
17
+ * @internal Bind (or clear) the singleton. Called by InkerProvider.start() to
18
+ * wire the renderer, or by tests passing `undefined` to reset between cases
19
+ * (the type field already permits `undefined`, so the signature mirrors it
20
+ * honestly rather than requiring a `bypassTypeCheck` cast at every call site).
21
+ */
22
+ export function setInker(renderer: InkerRenderer | undefined): void {
23
+ instance = renderer;
24
+ }
25
+
26
+ /** @internal Read the singleton (or `undefined` pre-boot). */
27
+ export function getInker(): InkerRenderer | undefined {
28
+ return instance;
29
+ }
30
+
31
+ // Sanctioned `as InkerRenderer` site — typed-Proxy idiom shared by
32
+ // station/rosetta/aurora/inker `services/main.ts`. Every property access is
33
+ // guarded by the `instance` check below, so the cast is structural and
34
+ // bounded. DNR `feedback_no_any_types` documents this as one of two
35
+ // permitted `as` patterns alongside `loadBearingCast` in `InkerProvider.ts`.
36
+ const inker: InkerRenderer = new Proxy({} as InkerRenderer, {
37
+ get(_target, prop) {
38
+ // Short-circuit the thenable probe: an accidental `await mod.default`
39
+ // (or `Promise.resolve(mod.default)`) would otherwise trigger our
40
+ // pre-boot throw inside the await machinery and surface a confusing
41
+ // rejected Promise. Returning `undefined` makes the value plainly
42
+ // non-thenable, so the caller's await resolves immediately to the
43
+ // Proxy itself — subsequent real property access still throws.
44
+ if (prop === "then") return undefined;
45
+ if (!instance) {
46
+ throw new Error(
47
+ "[inker] InkerRenderer singleton accessed before InkerProvider.start() ran " +
48
+ "or `setInker(myRenderer)` was called. Wire one of them first.",
49
+ );
50
+ }
51
+ const value = Reflect.get(instance, prop, instance);
52
+ return typeof value === "function" ? value.bind(instance) : value;
53
+ },
54
+ });
55
+
56
+ export default inker;