@patterkit/compiler 0.1.0

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/dist/index.cjs ADDED
@@ -0,0 +1,367 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ compileExpression: () => compileExpression,
24
+ exportBundle: () => exportBundle,
25
+ hostScopesToSpec: () => import_dialect3.hostScopesToSpec,
26
+ validateConditions: () => validateConditions,
27
+ validateInterpolation: () => validateInterpolation
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/expressions.ts
32
+ var import_expr = require("@wildwinter/expr");
33
+ var import_dialect = require("@patterkit/dialect");
34
+ var import_model = require("@patterkit/model");
35
+ function compileExpression(src, foreign) {
36
+ return (0, import_expr.compile)(src, (0, import_dialect.dialectWithForeignScopes)(foreign));
37
+ }
38
+ function validateConditions(input, options = {}) {
39
+ const issues = [];
40
+ const foreign = options.foreignScopes;
41
+ const dialect = (0, import_dialect.dialectWithForeignScopes)(foreign);
42
+ const readOnly = readOnlyForeignTargets(foreign);
43
+ const opaqueForeign = opaqueForeignTokens(foreign);
44
+ const isScopeToken = scopeTokenTest(foreign);
45
+ const nodeIds = collectNodeIds(input.scenes);
46
+ const checkVisitIds = (node, nodeId, field, src) => {
47
+ if (node.kind === "call") {
48
+ if (VISIT_FNS.has(node.name)) {
49
+ const arg = node.args[0];
50
+ if (arg && arg.kind === "string" && !nodeIds.has(arg.value)) {
51
+ issues.push({ nodeId, field, src, severity: "error", message: `${node.name}(): unknown node id '${arg.value}'` });
52
+ }
53
+ }
54
+ for (const a of node.args) checkVisitIds(a, nodeId, field, src);
55
+ } else if (node.kind === "binary") {
56
+ checkVisitIds(node.left, nodeId, field, src);
57
+ checkVisitIds(node.right, nodeId, field, src);
58
+ } else if (node.kind === "unary") {
59
+ checkVisitIds(node.operand, nodeId, field, src);
60
+ }
61
+ };
62
+ const validateElement = (blocks, sceneProps, onEntry, elementId) => {
63
+ const schema = (0, import_dialect.buildSchema)(input.project, sceneProps, foreign);
64
+ const check = (nodeId, field, src) => {
65
+ let ast;
66
+ try {
67
+ ast = (0, import_expr.parse)(src, dialect);
68
+ } catch (e) {
69
+ issues.push({ nodeId, field, src, severity: "error", message: e instanceof Error ? e.message : String(e) });
70
+ return;
71
+ }
72
+ for (const iss of (0, import_expr.validateExpr)(ast, schema, dialect)) {
73
+ issues.push({ nodeId, field, src, severity: iss.severity, message: iss.message });
74
+ }
75
+ checkVisitIds(ast, nodeId, field, src);
76
+ };
77
+ const checkTarget = (nodeId, field, target) => {
78
+ if (!TARGET_REF.test(target)) {
79
+ issues.push({
80
+ nodeId,
81
+ field,
82
+ src: target,
83
+ severity: "error",
84
+ message: `set target must be a property reference (@name / @scope.name), got '${target}'`
85
+ });
86
+ return;
87
+ }
88
+ if (readOnly.has(refKey(target))) {
89
+ issues.push({
90
+ nodeId,
91
+ field,
92
+ src: target,
93
+ severity: "error",
94
+ message: `cannot assign to read-only property '${target}'`
95
+ });
96
+ return;
97
+ }
98
+ const { scope, name } = (0, import_dialect.splitRef)(target, isScopeToken);
99
+ if (opaqueForeign.has(scope)) return;
100
+ if (!schema.properties.get(scope)?.has(name)) {
101
+ issues.push({
102
+ nodeId,
103
+ field,
104
+ src: target,
105
+ severity: "error",
106
+ message: `set target '${target}' is not a declared property`
107
+ });
108
+ }
109
+ };
110
+ const checkEffects = (nodeId, list, label) => {
111
+ for (const e of list ?? []) {
112
+ checkTarget(nodeId, `${label}.set`, e.target);
113
+ check(nodeId, `${label}.set`, e.value);
114
+ }
115
+ };
116
+ checkEffects(elementId, onEntry, "onEntry");
117
+ for (const block of blocks) {
118
+ (0, import_model.walkNodes)(block.children, (node) => {
119
+ if (node.condition) check(node.id, "condition", node.condition);
120
+ if (node.type === "snippet") {
121
+ checkEffects(node.id, node.onEnter, "onEnter");
122
+ checkEffects(node.id, node.onExit, "onExit");
123
+ }
124
+ });
125
+ }
126
+ };
127
+ for (const scene of input.scenes) validateElement(scene.blocks, scene.sceneProps, scene.onEntry, scene.id);
128
+ return issues;
129
+ }
130
+ function validateInterpolation(input, options = {}) {
131
+ const issues = [];
132
+ const voiced = input.project.voiced ?? false;
133
+ const tables = (input.locales ?? []).map((l) => ({ locale: l.locale, strings: l.strings }));
134
+ const defaultLocale = input.project.locales.default;
135
+ const opaqueForeign = opaqueForeignTokens(options.foreignScopes);
136
+ const isScopeToken = scopeTokenTest(options.foreignScopes);
137
+ for (const scene of input.scenes) {
138
+ const schema = (0, import_dialect.buildSchema)(input.project, scene.sceneProps, options.foreignScopes);
139
+ const known = (ref) => {
140
+ const { scope, name } = (0, import_dialect.splitRef)(ref, isScopeToken);
141
+ if (opaqueForeign.has(scope)) return true;
142
+ return schema.properties.get(scope)?.has(name) ?? false;
143
+ };
144
+ const checkString = (nodeId, id, voicedLine) => {
145
+ for (const t of tables) {
146
+ const text = t.strings[id];
147
+ if (text === void 0) continue;
148
+ const field = `text[${t.locale}]`;
149
+ for (const slot of (0, import_dialect.extractSlots)(text)) {
150
+ if (voicedLine) {
151
+ issues.push({
152
+ nodeId,
153
+ field,
154
+ src: slot.raw,
155
+ severity: "error",
156
+ message: `voiced line beats cannot contain interpolation ${slot.raw} (spec \xA716)`
157
+ });
158
+ continue;
159
+ }
160
+ if (!slot.ref) {
161
+ issues.push({
162
+ nodeId,
163
+ field,
164
+ src: slot.raw,
165
+ severity: "error",
166
+ message: `interpolation slot holds a bare property reference only, got '${slot.inner}' (spec \xA716)`
167
+ });
168
+ continue;
169
+ }
170
+ if (!known(slot.ref)) {
171
+ issues.push({
172
+ nodeId,
173
+ field,
174
+ src: slot.raw,
175
+ severity: "error",
176
+ message: `unknown property in interpolation slot: '${slot.ref}'`
177
+ });
178
+ }
179
+ }
180
+ }
181
+ };
182
+ const checkBeat = (beat) => {
183
+ if (beat.kind === "gameEvent") return;
184
+ checkString(beat.id, beat.id, beat.kind === "line" && voiced);
185
+ };
186
+ for (const block of scene.blocks) {
187
+ (0, import_model.walkNodes)(block.children, (node) => {
188
+ if (node.type === "snippet") {
189
+ for (const beat of node.beats ?? []) checkBeat(beat);
190
+ return;
191
+ }
192
+ if (node.prompt) checkBeat(node.prompt);
193
+ });
194
+ }
195
+ }
196
+ return issues;
197
+ }
198
+ var VISIT_FNS = /* @__PURE__ */ new Set(["visits", "seen", "patter_visits", "patter_seen"]);
199
+ var TARGET_REF = /^@[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)?$/;
200
+ function collectNodeIds(scenes) {
201
+ const ids = /* @__PURE__ */ new Set();
202
+ for (const scene of scenes) {
203
+ ids.add(scene.id);
204
+ for (const block of scene.blocks) {
205
+ ids.add(block.id);
206
+ (0, import_model.walkNodes)(block.children, (n) => ids.add(n.id));
207
+ }
208
+ }
209
+ return ids;
210
+ }
211
+ function opaqueForeignTokens(spec) {
212
+ return new Set((spec?.scopes ?? []).filter((s) => !s.declarations?.length).map((s) => s.token));
213
+ }
214
+ function scopeTokenTest(spec) {
215
+ const foreign = new Set((spec?.scopes ?? []).map((s) => s.token));
216
+ return (t) => t === "patter" || t === "scene" || foreign.has(t);
217
+ }
218
+ function refKey(ref) {
219
+ return ref.replace(/^@/, "").toLowerCase();
220
+ }
221
+ function readOnlyForeignTargets(spec) {
222
+ const out = /* @__PURE__ */ new Set();
223
+ for (const scope of spec?.scopes ?? []) {
224
+ const scopeWritable = scope.writable ?? true;
225
+ for (const d of scope.declarations ?? []) {
226
+ const writable = d.writable ?? scopeWritable;
227
+ if (!writable) out.add(`${scope.token}.${d.name}`.toLowerCase());
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
233
+ // src/index.ts
234
+ var import_dialect3 = require("@patterkit/dialect");
235
+
236
+ // src/export.ts
237
+ var import_core = require("@patterkit/core");
238
+ var import_dialect2 = require("@patterkit/dialect");
239
+ function compileEffect(e, foreign) {
240
+ return { kind: "set", target: e.target, value: compileExpression(e.value, foreign) };
241
+ }
242
+ function compileNode(node, foreign) {
243
+ if (node.type === "group") {
244
+ return {
245
+ id: node.id,
246
+ type: "group",
247
+ condition: node.condition !== void 0 ? compileExpression(node.condition, foreign) : void 0,
248
+ selector: node.selector,
249
+ shared: node.shared,
250
+ options: node.options,
251
+ children: node.children.map((c) => compileNode(c, foreign)),
252
+ gameData: node.gameData,
253
+ tags: node.tags,
254
+ // author tags (#215), accumulated at runtime
255
+ prompt: node.prompt,
256
+ // option-position only (spec §5)
257
+ secretUntilEligible: node.secretUntilEligible,
258
+ sticky: node.sticky,
259
+ fallback: node.fallback
260
+ };
261
+ }
262
+ return {
263
+ id: node.id,
264
+ type: "snippet",
265
+ condition: node.condition !== void 0 ? compileExpression(node.condition, foreign) : void 0,
266
+ beats: node.beats,
267
+ onEnter: node.onEnter?.map((e) => compileEffect(e, foreign)),
268
+ onExit: node.onExit?.map((e) => compileEffect(e, foreign)),
269
+ gameData: node.gameData,
270
+ tags: node.tags,
271
+ // author tags (#215)
272
+ jump: node.jump,
273
+ secretUntilEligible: node.secretUntilEligible,
274
+ sticky: node.sticky,
275
+ fallback: node.fallback
276
+ };
277
+ }
278
+ function compileBlock(block, foreign) {
279
+ return {
280
+ id: block.id,
281
+ type: "block",
282
+ name: block.name,
283
+ ...block.gameId ? { gameId: block.gameId } : {},
284
+ // host-facing address; runtime falls back to the name slug
285
+ children: block.children.map((c) => compileNode(c, foreign)),
286
+ gameData: block.gameData,
287
+ tags: block.tags
288
+ // author tags (#215)
289
+ };
290
+ }
291
+ function compileScene(scene, foreign) {
292
+ return {
293
+ id: scene.id,
294
+ type: "scene",
295
+ name: scene.name,
296
+ ...scene.gameId ? { gameId: scene.gameId } : {},
297
+ // host-facing address; runtime falls back to the name slug
298
+ gameData: scene.gameData,
299
+ tags: scene.tags,
300
+ // author tags (#215)
301
+ onEntry: scene.onEntry?.map((e) => compileEffect(e, foreign)),
302
+ sceneProps: scene.sceneProps,
303
+ blocks: scene.blocks.map((b) => compileBlock(b, foreign))
304
+ };
305
+ }
306
+ function exportBundle(input) {
307
+ const { project, scenes, locales = [], includeLocales } = input;
308
+ const foreignScopes = input.foreignScopes ?? (0, import_dialect2.hostScopesToSpec)(project.scopeRegistry);
309
+ const scenesOut = {};
310
+ for (const scene of scenes) scenesOut[scene.id] = compileScene(scene, foreignScopes);
311
+ const strings = {};
312
+ for (const loc of locales) {
313
+ if (includeLocales && !includeLocales.includes(loc.locale)) continue;
314
+ const table = strings[loc.locale] ??= {};
315
+ for (const [key, text] of Object.entries(loc.strings)) {
316
+ if (key in table && table[key] !== text) {
317
+ throw new Error(`duplicate string key '${key}' in locale '${loc.locale}' with differing text`);
318
+ }
319
+ table[key] = text;
320
+ }
321
+ }
322
+ return {
323
+ schema: "patter/bundle@0",
324
+ content: {
325
+ project: project.project.id,
326
+ // Binds saves to the content they were taken against, and gates bundle
327
+ // staleness (schema §10.5). A stable strict-JSON fingerprint - independent
328
+ // of the source-form trailing-comma policy.
329
+ // `scopeRegistry` is folded in only when present, so projects without host scopes keep their exact
330
+ // prior hash (no ripple to existing bundles / the conformance corpus).
331
+ hash: (0, import_core.hash32)((0, import_core.canonicalStringify)({
332
+ scenes: scenesOut,
333
+ strings,
334
+ properties: project.properties ?? [],
335
+ ...foreignScopes ? { scopeRegistry: foreignScopes } : {}
336
+ }, { trailingComma: false })),
337
+ // Structure-only fingerprint (live bundle refresh): the same hash with the string tables left
338
+ // out. Same structureHash + a different hash = a text-only edit, safe to swap in place with
339
+ // `engine.replaceStrings()`; a changed structureHash needs the full save/load hot swap.
340
+ structureHash: (0, import_core.hash32)((0, import_core.canonicalStringify)({
341
+ scenes: scenesOut,
342
+ properties: project.properties ?? [],
343
+ ...foreignScopes ? { scopeRegistry: foreignScopes } : {}
344
+ }, { trailingComma: false }))
345
+ },
346
+ voiced: project.voiced ?? false,
347
+ locales: { default: project.locales.default, included: Object.keys(strings) },
348
+ cast: project.cast?.map(({ notes, ...c }) => c),
349
+ // strip authoring-only notes from the runtime
350
+ properties: project.properties,
351
+ scopeRegistry: project.scopeRegistry,
352
+ gameDataFields: project.gameDataFields,
353
+ // Closed-caption delimiters ride along only when the project pins a non-default pair (#214); the
354
+ // runtime falls back to `[` / `]` when absent, so default projects keep their exact prior bundle.
355
+ ...project.closedCaptions ? { closedCaptions: project.closedCaptions } : {},
356
+ scenes: scenesOut,
357
+ strings
358
+ };
359
+ }
360
+ // Annotate the CommonJS export names for ESM import in node:
361
+ 0 && (module.exports = {
362
+ compileExpression,
363
+ exportBundle,
364
+ hostScopesToSpec,
365
+ validateConditions,
366
+ validateInterpolation
367
+ });
@@ -0,0 +1,74 @@
1
+ import { ScopeRegistrySpec } from '@wildwinter/scoperegistry';
2
+ import { Expression, ProjectFile, Scene, LocaleFile, Bundle } from '@patterkit/model';
3
+ export { hostScopesToSpec } from '@patterkit/dialect';
4
+
5
+ /**
6
+ * Compile a condition/effect `src` string to the bundle `{ src, ast }` envelope.
7
+ * Pass `foreign` to allow references into another owner's imported scopes (e.g.
8
+ * a storylet's `@world`); the parser needs every referenced scope registered.
9
+ */
10
+ declare function compileExpression(src: string, foreign?: ScopeRegistrySpec): Expression;
11
+ interface ConditionIssue {
12
+ /** Id of the node whose expression has the issue. */
13
+ nodeId: string;
14
+ /** Where on the node: "condition" / "onEnter.set" / "onExit.set" / ... */
15
+ field: string;
16
+ /** The offending source expression. */
17
+ src: string;
18
+ message: string;
19
+ /** "error" blocks a clean build; "warning" is advisory. Condition issues carry the expr
20
+ * validator's own severity; the compiler's own structural pushes are always errors. */
21
+ severity: "error" | "warning";
22
+ }
23
+ /**
24
+ * Parse + validate every condition and effect expression in a project's scenes.
25
+ * Uses the Patter dialect and a per-scene ExpressionSchema (global properties +
26
+ * the scene's scene-local properties). Pass `options.foreignScopes` (another
27
+ * owner's `scopeRegistrySpec`) to permit + validate references into imported
28
+ * scopes; writes (`set` effects) that target a read-only foreign property are
29
+ * reported. Returns an empty array when all are valid.
30
+ */
31
+ declare function validateConditions(input: {
32
+ project: ProjectFile;
33
+ scenes: Scene[];
34
+ }, options?: {
35
+ foreignScopes?: ScopeRegistrySpec;
36
+ }): ConditionIssue[];
37
+ /**
38
+ * Validate inline `{@ref}` interpolation slots in a project's localised strings
39
+ * (spec §16). Enforces the VO-safety guarantee - **a voiced project rejects any
40
+ * slot in a line beat** - plus the committed surface (a slot holds a bare
41
+ * property reference only) and that the referenced property is declared. Text
42
+ * beats and CHOICE LABELS always interpolate (labels are on-screen text even in
43
+ * voiced projects); game-event beats carry no localised content. Also flags an
44
+ * option `prompt` id with no string in the default locale (the runtime would
45
+ * display the raw id). Pass `options.foreignScopes` so slots into imported
46
+ * scopes (`{@world.x}`) resolve instead of reporting unknown. Returns an empty
47
+ * array when all are valid.
48
+ */
49
+ declare function validateInterpolation(input: {
50
+ project: ProjectFile;
51
+ scenes: Scene[];
52
+ locales?: LocaleFile[];
53
+ }, options?: {
54
+ foreignScopes?: ScopeRegistrySpec;
55
+ }): ConditionIssue[];
56
+
57
+ interface ExportInput {
58
+ project: ProjectFile;
59
+ scenes: Scene[];
60
+ /** Locale files to assemble (one or more per locale). */
61
+ locales?: LocaleFile[];
62
+ /** Assemble only these locales (schema §10.1 "selected locales"); default: all provided. */
63
+ includeLocales?: string[];
64
+ /**
65
+ * Another owner's `scopeRegistrySpec` (e.g. a storylet's), to permit compiling
66
+ * references into its imported scopes (`@world.x`). The parser rejects an
67
+ * unregistered scope token, so cross-engine refs need this.
68
+ */
69
+ foreignScopes?: ScopeRegistrySpec;
70
+ }
71
+ /** Compile a project's source into the runtime bundle. */
72
+ declare function exportBundle(input: ExportInput): Bundle;
73
+
74
+ export { type ConditionIssue, type ExportInput, compileExpression, exportBundle, validateConditions, validateInterpolation };
@@ -0,0 +1,74 @@
1
+ import { ScopeRegistrySpec } from '@wildwinter/scoperegistry';
2
+ import { Expression, ProjectFile, Scene, LocaleFile, Bundle } from '@patterkit/model';
3
+ export { hostScopesToSpec } from '@patterkit/dialect';
4
+
5
+ /**
6
+ * Compile a condition/effect `src` string to the bundle `{ src, ast }` envelope.
7
+ * Pass `foreign` to allow references into another owner's imported scopes (e.g.
8
+ * a storylet's `@world`); the parser needs every referenced scope registered.
9
+ */
10
+ declare function compileExpression(src: string, foreign?: ScopeRegistrySpec): Expression;
11
+ interface ConditionIssue {
12
+ /** Id of the node whose expression has the issue. */
13
+ nodeId: string;
14
+ /** Where on the node: "condition" / "onEnter.set" / "onExit.set" / ... */
15
+ field: string;
16
+ /** The offending source expression. */
17
+ src: string;
18
+ message: string;
19
+ /** "error" blocks a clean build; "warning" is advisory. Condition issues carry the expr
20
+ * validator's own severity; the compiler's own structural pushes are always errors. */
21
+ severity: "error" | "warning";
22
+ }
23
+ /**
24
+ * Parse + validate every condition and effect expression in a project's scenes.
25
+ * Uses the Patter dialect and a per-scene ExpressionSchema (global properties +
26
+ * the scene's scene-local properties). Pass `options.foreignScopes` (another
27
+ * owner's `scopeRegistrySpec`) to permit + validate references into imported
28
+ * scopes; writes (`set` effects) that target a read-only foreign property are
29
+ * reported. Returns an empty array when all are valid.
30
+ */
31
+ declare function validateConditions(input: {
32
+ project: ProjectFile;
33
+ scenes: Scene[];
34
+ }, options?: {
35
+ foreignScopes?: ScopeRegistrySpec;
36
+ }): ConditionIssue[];
37
+ /**
38
+ * Validate inline `{@ref}` interpolation slots in a project's localised strings
39
+ * (spec §16). Enforces the VO-safety guarantee - **a voiced project rejects any
40
+ * slot in a line beat** - plus the committed surface (a slot holds a bare
41
+ * property reference only) and that the referenced property is declared. Text
42
+ * beats and CHOICE LABELS always interpolate (labels are on-screen text even in
43
+ * voiced projects); game-event beats carry no localised content. Also flags an
44
+ * option `prompt` id with no string in the default locale (the runtime would
45
+ * display the raw id). Pass `options.foreignScopes` so slots into imported
46
+ * scopes (`{@world.x}`) resolve instead of reporting unknown. Returns an empty
47
+ * array when all are valid.
48
+ */
49
+ declare function validateInterpolation(input: {
50
+ project: ProjectFile;
51
+ scenes: Scene[];
52
+ locales?: LocaleFile[];
53
+ }, options?: {
54
+ foreignScopes?: ScopeRegistrySpec;
55
+ }): ConditionIssue[];
56
+
57
+ interface ExportInput {
58
+ project: ProjectFile;
59
+ scenes: Scene[];
60
+ /** Locale files to assemble (one or more per locale). */
61
+ locales?: LocaleFile[];
62
+ /** Assemble only these locales (schema §10.1 "selected locales"); default: all provided. */
63
+ includeLocales?: string[];
64
+ /**
65
+ * Another owner's `scopeRegistrySpec` (e.g. a storylet's), to permit compiling
66
+ * references into its imported scopes (`@world.x`). The parser rejects an
67
+ * unregistered scope token, so cross-engine refs need this.
68
+ */
69
+ foreignScopes?: ScopeRegistrySpec;
70
+ }
71
+ /** Compile a project's source into the runtime bundle. */
72
+ declare function exportBundle(input: ExportInput): Bundle;
73
+
74
+ export { type ConditionIssue, type ExportInput, compileExpression, exportBundle, validateConditions, validateInterpolation };
package/dist/index.js ADDED
@@ -0,0 +1,336 @@
1
+ // src/expressions.ts
2
+ import { compile, parse, validateExpr } from "@wildwinter/expr";
3
+ import { dialectWithForeignScopes, buildSchema, extractSlots, splitRef } from "@patterkit/dialect";
4
+ import { walkNodes } from "@patterkit/model";
5
+ function compileExpression(src, foreign) {
6
+ return compile(src, dialectWithForeignScopes(foreign));
7
+ }
8
+ function validateConditions(input, options = {}) {
9
+ const issues = [];
10
+ const foreign = options.foreignScopes;
11
+ const dialect = dialectWithForeignScopes(foreign);
12
+ const readOnly = readOnlyForeignTargets(foreign);
13
+ const opaqueForeign = opaqueForeignTokens(foreign);
14
+ const isScopeToken = scopeTokenTest(foreign);
15
+ const nodeIds = collectNodeIds(input.scenes);
16
+ const checkVisitIds = (node, nodeId, field, src) => {
17
+ if (node.kind === "call") {
18
+ if (VISIT_FNS.has(node.name)) {
19
+ const arg = node.args[0];
20
+ if (arg && arg.kind === "string" && !nodeIds.has(arg.value)) {
21
+ issues.push({ nodeId, field, src, severity: "error", message: `${node.name}(): unknown node id '${arg.value}'` });
22
+ }
23
+ }
24
+ for (const a of node.args) checkVisitIds(a, nodeId, field, src);
25
+ } else if (node.kind === "binary") {
26
+ checkVisitIds(node.left, nodeId, field, src);
27
+ checkVisitIds(node.right, nodeId, field, src);
28
+ } else if (node.kind === "unary") {
29
+ checkVisitIds(node.operand, nodeId, field, src);
30
+ }
31
+ };
32
+ const validateElement = (blocks, sceneProps, onEntry, elementId) => {
33
+ const schema = buildSchema(input.project, sceneProps, foreign);
34
+ const check = (nodeId, field, src) => {
35
+ let ast;
36
+ try {
37
+ ast = parse(src, dialect);
38
+ } catch (e) {
39
+ issues.push({ nodeId, field, src, severity: "error", message: e instanceof Error ? e.message : String(e) });
40
+ return;
41
+ }
42
+ for (const iss of validateExpr(ast, schema, dialect)) {
43
+ issues.push({ nodeId, field, src, severity: iss.severity, message: iss.message });
44
+ }
45
+ checkVisitIds(ast, nodeId, field, src);
46
+ };
47
+ const checkTarget = (nodeId, field, target) => {
48
+ if (!TARGET_REF.test(target)) {
49
+ issues.push({
50
+ nodeId,
51
+ field,
52
+ src: target,
53
+ severity: "error",
54
+ message: `set target must be a property reference (@name / @scope.name), got '${target}'`
55
+ });
56
+ return;
57
+ }
58
+ if (readOnly.has(refKey(target))) {
59
+ issues.push({
60
+ nodeId,
61
+ field,
62
+ src: target,
63
+ severity: "error",
64
+ message: `cannot assign to read-only property '${target}'`
65
+ });
66
+ return;
67
+ }
68
+ const { scope, name } = splitRef(target, isScopeToken);
69
+ if (opaqueForeign.has(scope)) return;
70
+ if (!schema.properties.get(scope)?.has(name)) {
71
+ issues.push({
72
+ nodeId,
73
+ field,
74
+ src: target,
75
+ severity: "error",
76
+ message: `set target '${target}' is not a declared property`
77
+ });
78
+ }
79
+ };
80
+ const checkEffects = (nodeId, list, label) => {
81
+ for (const e of list ?? []) {
82
+ checkTarget(nodeId, `${label}.set`, e.target);
83
+ check(nodeId, `${label}.set`, e.value);
84
+ }
85
+ };
86
+ checkEffects(elementId, onEntry, "onEntry");
87
+ for (const block of blocks) {
88
+ walkNodes(block.children, (node) => {
89
+ if (node.condition) check(node.id, "condition", node.condition);
90
+ if (node.type === "snippet") {
91
+ checkEffects(node.id, node.onEnter, "onEnter");
92
+ checkEffects(node.id, node.onExit, "onExit");
93
+ }
94
+ });
95
+ }
96
+ };
97
+ for (const scene of input.scenes) validateElement(scene.blocks, scene.sceneProps, scene.onEntry, scene.id);
98
+ return issues;
99
+ }
100
+ function validateInterpolation(input, options = {}) {
101
+ const issues = [];
102
+ const voiced = input.project.voiced ?? false;
103
+ const tables = (input.locales ?? []).map((l) => ({ locale: l.locale, strings: l.strings }));
104
+ const defaultLocale = input.project.locales.default;
105
+ const opaqueForeign = opaqueForeignTokens(options.foreignScopes);
106
+ const isScopeToken = scopeTokenTest(options.foreignScopes);
107
+ for (const scene of input.scenes) {
108
+ const schema = buildSchema(input.project, scene.sceneProps, options.foreignScopes);
109
+ const known = (ref) => {
110
+ const { scope, name } = splitRef(ref, isScopeToken);
111
+ if (opaqueForeign.has(scope)) return true;
112
+ return schema.properties.get(scope)?.has(name) ?? false;
113
+ };
114
+ const checkString = (nodeId, id, voicedLine) => {
115
+ for (const t of tables) {
116
+ const text = t.strings[id];
117
+ if (text === void 0) continue;
118
+ const field = `text[${t.locale}]`;
119
+ for (const slot of extractSlots(text)) {
120
+ if (voicedLine) {
121
+ issues.push({
122
+ nodeId,
123
+ field,
124
+ src: slot.raw,
125
+ severity: "error",
126
+ message: `voiced line beats cannot contain interpolation ${slot.raw} (spec \xA716)`
127
+ });
128
+ continue;
129
+ }
130
+ if (!slot.ref) {
131
+ issues.push({
132
+ nodeId,
133
+ field,
134
+ src: slot.raw,
135
+ severity: "error",
136
+ message: `interpolation slot holds a bare property reference only, got '${slot.inner}' (spec \xA716)`
137
+ });
138
+ continue;
139
+ }
140
+ if (!known(slot.ref)) {
141
+ issues.push({
142
+ nodeId,
143
+ field,
144
+ src: slot.raw,
145
+ severity: "error",
146
+ message: `unknown property in interpolation slot: '${slot.ref}'`
147
+ });
148
+ }
149
+ }
150
+ }
151
+ };
152
+ const checkBeat = (beat) => {
153
+ if (beat.kind === "gameEvent") return;
154
+ checkString(beat.id, beat.id, beat.kind === "line" && voiced);
155
+ };
156
+ for (const block of scene.blocks) {
157
+ walkNodes(block.children, (node) => {
158
+ if (node.type === "snippet") {
159
+ for (const beat of node.beats ?? []) checkBeat(beat);
160
+ return;
161
+ }
162
+ if (node.prompt) checkBeat(node.prompt);
163
+ });
164
+ }
165
+ }
166
+ return issues;
167
+ }
168
+ var VISIT_FNS = /* @__PURE__ */ new Set(["visits", "seen", "patter_visits", "patter_seen"]);
169
+ var TARGET_REF = /^@[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)?$/;
170
+ function collectNodeIds(scenes) {
171
+ const ids = /* @__PURE__ */ new Set();
172
+ for (const scene of scenes) {
173
+ ids.add(scene.id);
174
+ for (const block of scene.blocks) {
175
+ ids.add(block.id);
176
+ walkNodes(block.children, (n) => ids.add(n.id));
177
+ }
178
+ }
179
+ return ids;
180
+ }
181
+ function opaqueForeignTokens(spec) {
182
+ return new Set((spec?.scopes ?? []).filter((s) => !s.declarations?.length).map((s) => s.token));
183
+ }
184
+ function scopeTokenTest(spec) {
185
+ const foreign = new Set((spec?.scopes ?? []).map((s) => s.token));
186
+ return (t) => t === "patter" || t === "scene" || foreign.has(t);
187
+ }
188
+ function refKey(ref) {
189
+ return ref.replace(/^@/, "").toLowerCase();
190
+ }
191
+ function readOnlyForeignTargets(spec) {
192
+ const out = /* @__PURE__ */ new Set();
193
+ for (const scope of spec?.scopes ?? []) {
194
+ const scopeWritable = scope.writable ?? true;
195
+ for (const d of scope.declarations ?? []) {
196
+ const writable = d.writable ?? scopeWritable;
197
+ if (!writable) out.add(`${scope.token}.${d.name}`.toLowerCase());
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+
203
+ // src/index.ts
204
+ import { hostScopesToSpec as hostScopesToSpec2 } from "@patterkit/dialect";
205
+
206
+ // src/export.ts
207
+ import { canonicalStringify, hash32 } from "@patterkit/core";
208
+ import { hostScopesToSpec } from "@patterkit/dialect";
209
+ function compileEffect(e, foreign) {
210
+ return { kind: "set", target: e.target, value: compileExpression(e.value, foreign) };
211
+ }
212
+ function compileNode(node, foreign) {
213
+ if (node.type === "group") {
214
+ return {
215
+ id: node.id,
216
+ type: "group",
217
+ condition: node.condition !== void 0 ? compileExpression(node.condition, foreign) : void 0,
218
+ selector: node.selector,
219
+ shared: node.shared,
220
+ options: node.options,
221
+ children: node.children.map((c) => compileNode(c, foreign)),
222
+ gameData: node.gameData,
223
+ tags: node.tags,
224
+ // author tags (#215), accumulated at runtime
225
+ prompt: node.prompt,
226
+ // option-position only (spec §5)
227
+ secretUntilEligible: node.secretUntilEligible,
228
+ sticky: node.sticky,
229
+ fallback: node.fallback
230
+ };
231
+ }
232
+ return {
233
+ id: node.id,
234
+ type: "snippet",
235
+ condition: node.condition !== void 0 ? compileExpression(node.condition, foreign) : void 0,
236
+ beats: node.beats,
237
+ onEnter: node.onEnter?.map((e) => compileEffect(e, foreign)),
238
+ onExit: node.onExit?.map((e) => compileEffect(e, foreign)),
239
+ gameData: node.gameData,
240
+ tags: node.tags,
241
+ // author tags (#215)
242
+ jump: node.jump,
243
+ secretUntilEligible: node.secretUntilEligible,
244
+ sticky: node.sticky,
245
+ fallback: node.fallback
246
+ };
247
+ }
248
+ function compileBlock(block, foreign) {
249
+ return {
250
+ id: block.id,
251
+ type: "block",
252
+ name: block.name,
253
+ ...block.gameId ? { gameId: block.gameId } : {},
254
+ // host-facing address; runtime falls back to the name slug
255
+ children: block.children.map((c) => compileNode(c, foreign)),
256
+ gameData: block.gameData,
257
+ tags: block.tags
258
+ // author tags (#215)
259
+ };
260
+ }
261
+ function compileScene(scene, foreign) {
262
+ return {
263
+ id: scene.id,
264
+ type: "scene",
265
+ name: scene.name,
266
+ ...scene.gameId ? { gameId: scene.gameId } : {},
267
+ // host-facing address; runtime falls back to the name slug
268
+ gameData: scene.gameData,
269
+ tags: scene.tags,
270
+ // author tags (#215)
271
+ onEntry: scene.onEntry?.map((e) => compileEffect(e, foreign)),
272
+ sceneProps: scene.sceneProps,
273
+ blocks: scene.blocks.map((b) => compileBlock(b, foreign))
274
+ };
275
+ }
276
+ function exportBundle(input) {
277
+ const { project, scenes, locales = [], includeLocales } = input;
278
+ const foreignScopes = input.foreignScopes ?? hostScopesToSpec(project.scopeRegistry);
279
+ const scenesOut = {};
280
+ for (const scene of scenes) scenesOut[scene.id] = compileScene(scene, foreignScopes);
281
+ const strings = {};
282
+ for (const loc of locales) {
283
+ if (includeLocales && !includeLocales.includes(loc.locale)) continue;
284
+ const table = strings[loc.locale] ??= {};
285
+ for (const [key, text] of Object.entries(loc.strings)) {
286
+ if (key in table && table[key] !== text) {
287
+ throw new Error(`duplicate string key '${key}' in locale '${loc.locale}' with differing text`);
288
+ }
289
+ table[key] = text;
290
+ }
291
+ }
292
+ return {
293
+ schema: "patter/bundle@0",
294
+ content: {
295
+ project: project.project.id,
296
+ // Binds saves to the content they were taken against, and gates bundle
297
+ // staleness (schema §10.5). A stable strict-JSON fingerprint - independent
298
+ // of the source-form trailing-comma policy.
299
+ // `scopeRegistry` is folded in only when present, so projects without host scopes keep their exact
300
+ // prior hash (no ripple to existing bundles / the conformance corpus).
301
+ hash: hash32(canonicalStringify({
302
+ scenes: scenesOut,
303
+ strings,
304
+ properties: project.properties ?? [],
305
+ ...foreignScopes ? { scopeRegistry: foreignScopes } : {}
306
+ }, { trailingComma: false })),
307
+ // Structure-only fingerprint (live bundle refresh): the same hash with the string tables left
308
+ // out. Same structureHash + a different hash = a text-only edit, safe to swap in place with
309
+ // `engine.replaceStrings()`; a changed structureHash needs the full save/load hot swap.
310
+ structureHash: hash32(canonicalStringify({
311
+ scenes: scenesOut,
312
+ properties: project.properties ?? [],
313
+ ...foreignScopes ? { scopeRegistry: foreignScopes } : {}
314
+ }, { trailingComma: false }))
315
+ },
316
+ voiced: project.voiced ?? false,
317
+ locales: { default: project.locales.default, included: Object.keys(strings) },
318
+ cast: project.cast?.map(({ notes, ...c }) => c),
319
+ // strip authoring-only notes from the runtime
320
+ properties: project.properties,
321
+ scopeRegistry: project.scopeRegistry,
322
+ gameDataFields: project.gameDataFields,
323
+ // Closed-caption delimiters ride along only when the project pins a non-default pair (#214); the
324
+ // runtime falls back to `[` / `]` when absent, so default projects keep their exact prior bundle.
325
+ ...project.closedCaptions ? { closedCaptions: project.closedCaptions } : {},
326
+ scenes: scenesOut,
327
+ strings
328
+ };
329
+ }
330
+ export {
331
+ compileExpression,
332
+ exportBundle,
333
+ hostScopesToSpec2 as hostScopesToSpec,
334
+ validateConditions,
335
+ validateInterpolation
336
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@patterkit/compiler",
3
+ "version": "0.1.0",
4
+ "description": "Patter compiler: compile condition/effect expressions, validate them, and export source to the runtime bundle.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Ian Thomas",
8
+ "homepage": "https://patterkit.dev",
9
+ "repository": { "type": "git", "url": "git+https://github.com/patterkit/patter.git", "directory": "packages/compiler" },
10
+ "bugs": "https://github.com/patterkit/patter/issues",
11
+ "publishConfig": { "access": "public" },
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "require": "./dist/index.cjs"
20
+ }
21
+ },
22
+ "files": ["dist", "README.md"],
23
+ "sideEffects": false,
24
+ "scripts": {
25
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean"
26
+ },
27
+ "dependencies": {
28
+ "@patterkit/model": "0.1.0",
29
+ "@patterkit/core": "0.1.0",
30
+ "@patterkit/dialect": "0.1.0",
31
+ "@wildwinter/expr": "^0.3.0",
32
+ "@wildwinter/scoperegistry": "^0.1.0"
33
+ }
34
+ }