@patterkit/core 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 +468 -0
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +422 -0
- package/package.json +31 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
canonicalStringify: () => canonicalStringify,
|
|
34
|
+
effectiveGameId: () => import_model3.effectiveGameId,
|
|
35
|
+
gameIdify: () => import_model3.gameIdify,
|
|
36
|
+
hash32: () => hash32,
|
|
37
|
+
hash4: () => hash4,
|
|
38
|
+
isValidGameId: () => import_model3.isValidGameId,
|
|
39
|
+
newId: () => newId,
|
|
40
|
+
parseSource: () => parseSource,
|
|
41
|
+
slug: () => slug,
|
|
42
|
+
validateProject: () => validateProject
|
|
43
|
+
});
|
|
44
|
+
module.exports = __toCommonJS(index_exports);
|
|
45
|
+
|
|
46
|
+
// src/ids.ts
|
|
47
|
+
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
48
|
+
function newId(prefix = "", length = 8) {
|
|
49
|
+
const limit = 256 - 256 % ALPHABET.length;
|
|
50
|
+
let token = "";
|
|
51
|
+
while (token.length < length) {
|
|
52
|
+
const bytes = new Uint8Array(length * 2);
|
|
53
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
54
|
+
for (const b of bytes) {
|
|
55
|
+
if (b >= limit) continue;
|
|
56
|
+
token += ALPHABET[b % ALPHABET.length];
|
|
57
|
+
if (token.length === length) break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return prefix ? `${prefix}_${token}` : token;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/handle.ts
|
|
64
|
+
function slug(name) {
|
|
65
|
+
const s = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
66
|
+
return s || "_";
|
|
67
|
+
}
|
|
68
|
+
function fnv32(input) {
|
|
69
|
+
let h = 2166136261;
|
|
70
|
+
for (let i = 0; i < input.length; i++) {
|
|
71
|
+
h ^= input.charCodeAt(i);
|
|
72
|
+
h = Math.imul(h, 16777619);
|
|
73
|
+
}
|
|
74
|
+
return h >>> 0;
|
|
75
|
+
}
|
|
76
|
+
function hash4(input) {
|
|
77
|
+
return fnv32(input).toString(36).slice(-4).padStart(4, "0");
|
|
78
|
+
}
|
|
79
|
+
function hash32(input) {
|
|
80
|
+
return fnv32(input).toString(36).padStart(7, "0");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/index.ts
|
|
84
|
+
var import_model3 = require("@patterkit/model");
|
|
85
|
+
|
|
86
|
+
// src/serialize.ts
|
|
87
|
+
var import_json5 = __toESM(require("json5"), 1);
|
|
88
|
+
function parseSource(text) {
|
|
89
|
+
const body = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
90
|
+
return import_json5.default.parse(body);
|
|
91
|
+
}
|
|
92
|
+
function canonicalStringify(value, opts) {
|
|
93
|
+
return write(value, "", opts?.trailingComma ?? true) + "\n";
|
|
94
|
+
}
|
|
95
|
+
function write(v, indent, tc) {
|
|
96
|
+
if (v === null || typeof v === "boolean" || typeof v === "number") return JSON.stringify(v);
|
|
97
|
+
if (typeof v === "string") return JSON.stringify(v);
|
|
98
|
+
const tail = tc ? "," : "";
|
|
99
|
+
if (Array.isArray(v)) {
|
|
100
|
+
if (v.length === 0) return "[]";
|
|
101
|
+
const next = indent + " ";
|
|
102
|
+
const items = v.map((x) => next + write(x, next, tc));
|
|
103
|
+
return `[
|
|
104
|
+
${items.join(",\n")}${tail}
|
|
105
|
+
${indent}]`;
|
|
106
|
+
}
|
|
107
|
+
if (typeof v === "object") {
|
|
108
|
+
const obj = v;
|
|
109
|
+
const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
|
|
110
|
+
if (keys.length === 0) return "{}";
|
|
111
|
+
const next = indent + " ";
|
|
112
|
+
const entries = keys.map((k) => `${next}${JSON.stringify(k)}: ${write(obj[k], next, tc)}`);
|
|
113
|
+
return `{
|
|
114
|
+
${entries.join(",\n")}${tail}
|
|
115
|
+
${indent}}`;
|
|
116
|
+
}
|
|
117
|
+
return "null";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/validate.ts
|
|
121
|
+
var import_model = require("@patterkit/model");
|
|
122
|
+
var import_model2 = require("@patterkit/model");
|
|
123
|
+
function choiceLoopsBack(choice, ...targets) {
|
|
124
|
+
const back = new Set(targets.filter((s) => typeof s === "string" && s.length > 0));
|
|
125
|
+
const hits = (n) => n.type === "snippet" ? !!n.jump && back.has(n.jump.to) : (n.children ?? []).some(hits);
|
|
126
|
+
return (choice.children ?? []).some(hits);
|
|
127
|
+
}
|
|
128
|
+
function isContentlessNode(node) {
|
|
129
|
+
if (node.type === "snippet") return (node.beats?.length ?? 0) === 0 && !node.jump;
|
|
130
|
+
if (node.prompt) return false;
|
|
131
|
+
return Array.isArray(node.children) && node.children.every(isContentlessNode);
|
|
132
|
+
}
|
|
133
|
+
function validateProject(input) {
|
|
134
|
+
const { project, scenes } = input;
|
|
135
|
+
const issues = [];
|
|
136
|
+
const allIds = /* @__PURE__ */ new Map();
|
|
137
|
+
const addressable = /* @__PURE__ */ new Set();
|
|
138
|
+
const sceneGameIds = /* @__PURE__ */ new Map();
|
|
139
|
+
const jumps = [];
|
|
140
|
+
const castNames = new Set((project.cast ?? []).map((c) => c.name));
|
|
141
|
+
const checkTags = (tags, where, id) => {
|
|
142
|
+
for (const t of tags ?? []) {
|
|
143
|
+
if (typeof t !== "string" || t.length === 0 || /[\s,]/.test(t)) {
|
|
144
|
+
issues.push({ code: "invalid-tag", message: `${where} has an invalid tag '${String(t)}' (no spaces or commas, and not empty)`, id });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const seeId = (id, kind, where) => {
|
|
149
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
150
|
+
issues.push({ code: "missing-id", message: `${kind} in ${where} has no id` });
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const existing = allIds.get(id);
|
|
154
|
+
if (existing !== void 0) {
|
|
155
|
+
issues.push({ code: "duplicate-id", message: `duplicate id '${id}' (${kind} and ${existing})`, id });
|
|
156
|
+
} else {
|
|
157
|
+
allIds.set(id, kind);
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
};
|
|
161
|
+
validateProjectFile(project, issues);
|
|
162
|
+
for (const scene of scenes) {
|
|
163
|
+
seeId(scene.id, "scene", "project");
|
|
164
|
+
const where = `scene '${scene.id}'`;
|
|
165
|
+
if (typeof scene.id === "string") addressable.add(scene.id);
|
|
166
|
+
if (!scene.name?.trim()) {
|
|
167
|
+
issues.push({ code: "missing-name", message: `${where} has no name`, id: scene.id });
|
|
168
|
+
}
|
|
169
|
+
if (scene.gameId && !(0, import_model2.isValidGameId)(scene.gameId)) {
|
|
170
|
+
issues.push({ code: "invalid-gameid", message: `${where} address '${scene.gameId}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)`, id: scene.id });
|
|
171
|
+
}
|
|
172
|
+
const sgid = (0, import_model2.effectiveGameId)(scene);
|
|
173
|
+
if (sgid) {
|
|
174
|
+
const prev = sceneGameIds.get(sgid);
|
|
175
|
+
if (prev && prev !== scene.id) issues.push({ code: "duplicate-gameid", message: `${where} address '${sgid}' is already used by scene '${prev}'`, id: scene.id });
|
|
176
|
+
else sceneGameIds.set(sgid, scene.id);
|
|
177
|
+
}
|
|
178
|
+
const blockGameIds = /* @__PURE__ */ new Map();
|
|
179
|
+
checkTags(scene.tags, where, scene.id);
|
|
180
|
+
checkDecls(scene.sceneProps, `${where} sceneProps`, issues);
|
|
181
|
+
for (const decl of scene.sceneProps ?? []) {
|
|
182
|
+
if (decl.temporary && (decl.shared ?? false)) {
|
|
183
|
+
issues.push({
|
|
184
|
+
code: "invalid-temporary",
|
|
185
|
+
message: `${where} property '${decl.name}': 'temporary' cannot be combined with 'shared'`,
|
|
186
|
+
id: scene.id
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!Array.isArray(scene.blocks)) {
|
|
191
|
+
issues.push({ code: "malformed-node", message: `${where} has no blocks array`, id: scene.id });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (scene.blocks.length === 0) {
|
|
195
|
+
issues.push({ code: "empty-scene", message: `${where} has no blocks`, id: scene.id });
|
|
196
|
+
}
|
|
197
|
+
for (const block of scene.blocks) {
|
|
198
|
+
seeId(block.id, "block", where);
|
|
199
|
+
checkTags(block.tags, `block '${block.id}'`, block.id);
|
|
200
|
+
if (typeof block.id === "string") addressable.add(block.id);
|
|
201
|
+
if (!block.name?.trim()) {
|
|
202
|
+
issues.push({ code: "missing-name", message: `block '${block.id}' has no name`, id: block.id });
|
|
203
|
+
}
|
|
204
|
+
if (block.gameId && !(0, import_model2.isValidGameId)(block.gameId)) {
|
|
205
|
+
issues.push({ code: "invalid-gameid", message: `block '${block.id}' address '${block.gameId}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)`, id: block.id });
|
|
206
|
+
}
|
|
207
|
+
const bgid = (0, import_model2.effectiveGameId)(block);
|
|
208
|
+
if (bgid) {
|
|
209
|
+
const prev = blockGameIds.get(bgid);
|
|
210
|
+
if (prev && prev !== block.id) issues.push({ code: "duplicate-gameid", message: `block '${block.id}' address '${bgid}' is already used by block '${prev}' in ${where}`, id: block.id });
|
|
211
|
+
else blockGameIds.set(bgid, block.id);
|
|
212
|
+
}
|
|
213
|
+
if (!Array.isArray(block.children)) {
|
|
214
|
+
issues.push({ code: "malformed-node", message: `block '${block.id}' in ${where} has no children array`, id: block.id });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const inBlockOf = `block '${block.id}'`;
|
|
218
|
+
const reportEmpty = (nodes) => {
|
|
219
|
+
for (const n of nodes) {
|
|
220
|
+
if (isContentlessNode(n)) {
|
|
221
|
+
if (n.type === "group") {
|
|
222
|
+
issues.push({ code: "empty-container", message: `group '${n.id}' in ${inBlockOf} is empty`, id: n.id });
|
|
223
|
+
} else {
|
|
224
|
+
issues.push({ code: "empty-snippet", message: `snippet '${n.id}' has no beats and no jump`, id: n.id });
|
|
225
|
+
}
|
|
226
|
+
} else if (n.type === "group" && Array.isArray(n.children)) {
|
|
227
|
+
reportEmpty(n.children);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
if (block.children.length === 0 || block.children.every(isContentlessNode)) {
|
|
232
|
+
issues.push({ code: "empty-container", message: `block '${block.id}' in ${where} is empty`, id: block.id });
|
|
233
|
+
} else {
|
|
234
|
+
reportEmpty(block.children);
|
|
235
|
+
}
|
|
236
|
+
(0, import_model.walkNodes)(block.children, (node) => {
|
|
237
|
+
const inBlock = `block '${block.id}'`;
|
|
238
|
+
checkTags(node.tags, `${node.type} '${node.id}'`, node.id);
|
|
239
|
+
if (node.type === "group") {
|
|
240
|
+
seeId(node.id, "group", inBlock);
|
|
241
|
+
if (!Array.isArray(node.children)) {
|
|
242
|
+
issues.push({ code: "malformed-node", message: `group '${node.id}' in ${inBlock} has no children array`, id: node.id });
|
|
243
|
+
}
|
|
244
|
+
if (node.selector === "choice") {
|
|
245
|
+
let fallbacks = 0;
|
|
246
|
+
let hasSticky = false;
|
|
247
|
+
for (const opt of node.children ?? []) {
|
|
248
|
+
if (opt.fallback === true) fallbacks++;
|
|
249
|
+
if (opt.sticky === true) hasSticky = true;
|
|
250
|
+
if (opt.type !== "group") continue;
|
|
251
|
+
const p = opt.prompt;
|
|
252
|
+
if (!p) {
|
|
253
|
+
issues.push({ code: "missing-prompt", message: `choice option '${opt.id}' in ${inBlock} has no prompt`, id: opt.id });
|
|
254
|
+
} else {
|
|
255
|
+
if (p.kind !== "line" && p.kind !== "text") {
|
|
256
|
+
issues.push({ code: "invalid-prompt", message: `choice option '${opt.id}' prompt must be a line or text beat`, id: opt.id });
|
|
257
|
+
}
|
|
258
|
+
seeId(p.id, "prompt", `choice option '${opt.id}'`);
|
|
259
|
+
checkTags(p.tags, `prompt '${p.id}'`, p.id);
|
|
260
|
+
if (p.kind === "line" && p.character && !castNames.has(p.character)) {
|
|
261
|
+
issues.push({ code: "unknown-character", message: `prompt '${p.id}' speaker '${p.character}' is not in the project cast`, id: p.id });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (fallbacks > 1) {
|
|
266
|
+
issues.push({ code: "multiple-fallbacks", message: `choice '${node.id}' in ${inBlock} has ${fallbacks} fallback options (at most one allowed)`, id: node.id });
|
|
267
|
+
}
|
|
268
|
+
const hasUnconditionalEscape = (node.children ?? []).some((opt) => !opt.condition);
|
|
269
|
+
if ((node.children?.length ?? 0) > 0 && !hasUnconditionalEscape) {
|
|
270
|
+
issues.push({ code: "choice-can-empty", message: `choice '${node.id}' in ${inBlock} has no unconditional option or fallback - it runs dry (falls through) if every condition fails`, id: node.id });
|
|
271
|
+
} else if (!hasSticky && fallbacks === 0 && choiceLoopsBack(node, block.id, bgid, scene.id, sgid)) {
|
|
272
|
+
issues.push({ code: "choice-can-empty", message: `choice '${node.id}' in ${inBlock} can be re-entered but every option is once-only with no fallback - it will run dry`, id: node.id });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
seeId(node.id, "snippet", inBlock);
|
|
278
|
+
for (const beat of node.beats ?? []) {
|
|
279
|
+
seeId(beat.id, "beat", `snippet '${node.id}'`);
|
|
280
|
+
checkTags(beat.tags, `beat '${beat.id}'`, beat.id);
|
|
281
|
+
if (beat.kind === "line" && beat.character && !castNames.has(beat.character)) {
|
|
282
|
+
issues.push({
|
|
283
|
+
code: "unknown-character",
|
|
284
|
+
message: `beat '${beat.id}' speaker '${beat.character}' is not in the project cast`,
|
|
285
|
+
id: beat.id
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (node.jump) jumps.push({ to: node.jump.to, from: node.id });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (input.authoring) validateAuthoring(input.authoring, project, allIds, issues);
|
|
294
|
+
for (const d of jumps) {
|
|
295
|
+
if (d.to === "END") continue;
|
|
296
|
+
if (!allIds.has(d.to)) {
|
|
297
|
+
issues.push({ code: "dangling-jump", message: `jump in '${d.from}' targets unknown id '${d.to}'`, id: d.from });
|
|
298
|
+
} else if (!addressable.has(d.to)) {
|
|
299
|
+
issues.push({
|
|
300
|
+
code: "jump-into-non-addressable",
|
|
301
|
+
message: `jump in '${d.from}' targets '${d.to}' (${allIds.get(d.to)}); jumps may only target a scene, block, or END`,
|
|
302
|
+
id: d.from
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return issues;
|
|
307
|
+
}
|
|
308
|
+
function validateProjectFile(project, issues) {
|
|
309
|
+
checkDecls(project.properties, "project properties", issues);
|
|
310
|
+
for (const decl of project.properties ?? []) {
|
|
311
|
+
if (decl.temporary) {
|
|
312
|
+
issues.push({
|
|
313
|
+
code: "invalid-temporary",
|
|
314
|
+
message: `global property '${decl.name}': 'temporary' applies only to scene-local (@scene) properties`
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (project.locales && !project.locales.all.includes(project.locales.default)) {
|
|
319
|
+
issues.push({
|
|
320
|
+
code: "invalid-locales",
|
|
321
|
+
message: `default locale '${project.locales.default}' is not in locales.all [${project.locales.all.join(", ")}]`
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const cc = project.closedCaptions;
|
|
325
|
+
if (cc && (cc.open.length === 0 || cc.close.length === 0)) {
|
|
326
|
+
issues.push({
|
|
327
|
+
code: "invalid-captions",
|
|
328
|
+
message: "closed-caption delimiters must both be non-empty"
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const writing = project.writingStatuses;
|
|
332
|
+
if (writing) {
|
|
333
|
+
checkLadderNames(writing.map((s) => s.name), "writingStatuses", issues);
|
|
334
|
+
for (const marker of ["readyToRecord", "readyToShip"]) {
|
|
335
|
+
const count = writing.filter((s) => s[marker]).length;
|
|
336
|
+
if (count !== 1) {
|
|
337
|
+
issues.push({
|
|
338
|
+
code: "invalid-status-ladder",
|
|
339
|
+
message: `writingStatuses must declare '${marker}' on exactly one status (found ${count})`
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (project.recordingStatuses) {
|
|
345
|
+
checkLadderNames(project.recordingStatuses.map((s) => s.name), "recordingStatuses", issues);
|
|
346
|
+
}
|
|
347
|
+
for (const [kind, fields] of Object.entries(project.gameDataFields ?? {})) {
|
|
348
|
+
const seen = /* @__PURE__ */ new Set();
|
|
349
|
+
for (const f of fields ?? []) {
|
|
350
|
+
const key = f.name.trim().toLowerCase();
|
|
351
|
+
if (!f.name.trim()) issues.push({ code: "invalid-gamedata-field", message: `${kind} gameData field has no name` });
|
|
352
|
+
else if (seen.has(key)) issues.push({ code: "invalid-gamedata-field", message: `${kind} gameData field '${f.name}' is declared more than once` });
|
|
353
|
+
else seen.add(key);
|
|
354
|
+
if (f.type === "enum" && !(f.values && f.values.length)) {
|
|
355
|
+
issues.push({ code: "invalid-gamedata-field", message: `${kind} gameData field '${f.name}' is an enum but lists no values` });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function validateAuthoring(files, project, allIds, issues) {
|
|
361
|
+
const writingLadder = new Set((project.writingStatuses ?? import_model.DEFAULT_WRITING_STATUSES).map((s) => s.name));
|
|
362
|
+
const recordingLadder = new Set((project.recordingStatuses ?? import_model.DEFAULT_RECORDING_STATUSES).map((s) => s.name));
|
|
363
|
+
const docClasses = new Set((project.documentationClasses ?? import_model.DEFAULT_DOCUMENTATION_CLASSES).map((c) => c.name));
|
|
364
|
+
const checkLines = (lines, where) => {
|
|
365
|
+
if (lines !== void 0 && (typeof lines !== "number" || !Number.isFinite(lines) || lines < 0)) {
|
|
366
|
+
issues.push({ code: "invalid-status-value", message: `${where}: estimate lines must be a non-negative number, got ${JSON.stringify(lines)}` });
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const est = project.estimating;
|
|
370
|
+
if (est) {
|
|
371
|
+
if (est.thresholdStatus !== void 0 && !writingLadder.has(est.thresholdStatus)) {
|
|
372
|
+
issues.push({ code: "invalid-status-value", message: `estimating: threshold status '${est.thresholdStatus}' is not in the writing ladder` });
|
|
373
|
+
}
|
|
374
|
+
checkLines(est.defaultLines, "estimating default");
|
|
375
|
+
for (const tg of est.tagEstimates ?? []) checkLines(tg.lines, `estimating tag '${tg.tag}'`);
|
|
376
|
+
}
|
|
377
|
+
for (const file of files) {
|
|
378
|
+
for (const [id, value] of Object.entries(file.writing ?? {})) {
|
|
379
|
+
if (!allIds.has(id)) issues.push({ code: "unknown-status-id", message: `writing status set on unknown id '${id}'`, id });
|
|
380
|
+
if (!writingLadder.has(value)) {
|
|
381
|
+
issues.push({ code: "invalid-status-value", message: `writing status '${value}' on '${id}' is not in the project ladder`, id });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const [id, value] of Object.entries(file.recording ?? {})) {
|
|
385
|
+
if (!allIds.has(id)) issues.push({ code: "unknown-status-id", message: `recording status set on unknown id '${id}'`, id });
|
|
386
|
+
if (!recordingLadder.has(value)) {
|
|
387
|
+
issues.push({ code: "invalid-status-value", message: `recording status '${value}' on '${id}' is not in the project ladder`, id });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const id of Object.keys(file.cut ?? {})) {
|
|
391
|
+
if (!allIds.has(id)) issues.push({ code: "unknown-status-id", message: `cut flag set on unknown id '${id}'`, id });
|
|
392
|
+
}
|
|
393
|
+
for (const [id, lines] of Object.entries(file.documentation ?? {})) {
|
|
394
|
+
if (!allIds.has(id)) issues.push({ code: "unknown-status-id", message: `documentation set on unknown id '${id}'`, id });
|
|
395
|
+
for (const line of lines) {
|
|
396
|
+
if (line.type !== void 0 && !docClasses.has(line.type)) {
|
|
397
|
+
issues.push({ code: "unknown-doc-class", message: `documentation class '${line.type}' on '${id}' is not declared (project documentationClasses)`, id });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function checkLadderNames(names, where, issues) {
|
|
404
|
+
const seen = /* @__PURE__ */ new Set();
|
|
405
|
+
for (const name of names) {
|
|
406
|
+
if (!name?.trim()) {
|
|
407
|
+
issues.push({ code: "invalid-status-ladder", message: `${where} has an empty status name` });
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (seen.has(name)) {
|
|
411
|
+
issues.push({ code: "invalid-status-ladder", message: `${where} declares '${name}' more than once` });
|
|
412
|
+
}
|
|
413
|
+
seen.add(name);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function checkDecls(decls, where, issues) {
|
|
417
|
+
const seen = /* @__PURE__ */ new Set();
|
|
418
|
+
for (const decl of decls ?? []) {
|
|
419
|
+
const name = decl.name?.toLowerCase();
|
|
420
|
+
if (!name) {
|
|
421
|
+
issues.push({ code: "invalid-declaration", message: `${where}: a property declaration has no name` });
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (seen.has(name)) {
|
|
425
|
+
issues.push({ code: "invalid-declaration", message: `${where}: duplicate property '${decl.name}'` });
|
|
426
|
+
}
|
|
427
|
+
seen.add(name);
|
|
428
|
+
if ((decl.type === "enum" || decl.type === "flags") && !decl.values?.length) {
|
|
429
|
+
issues.push({
|
|
430
|
+
code: "invalid-declaration",
|
|
431
|
+
message: `${where}: '${decl.name}' is ${decl.type} but declares no values`
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (decl.default !== void 0 && !defaultMatchesType(decl.default, decl)) {
|
|
435
|
+
issues.push({
|
|
436
|
+
code: "invalid-declaration",
|
|
437
|
+
message: `${where}: '${decl.name}' default ${JSON.stringify(decl.default)} does not match type '${decl.type}'`
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function defaultMatchesType(value, decl) {
|
|
443
|
+
switch (decl.type) {
|
|
444
|
+
case "boolean":
|
|
445
|
+
return typeof value === "boolean";
|
|
446
|
+
case "number":
|
|
447
|
+
return typeof value === "number";
|
|
448
|
+
case "string":
|
|
449
|
+
return typeof value === "string";
|
|
450
|
+
case "enum":
|
|
451
|
+
return typeof value === "string" && (decl.values?.includes(value) ?? true);
|
|
452
|
+
case "flags":
|
|
453
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string" && (decl.values?.includes(v) ?? true));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
457
|
+
0 && (module.exports = {
|
|
458
|
+
canonicalStringify,
|
|
459
|
+
effectiveGameId,
|
|
460
|
+
gameIdify,
|
|
461
|
+
hash32,
|
|
462
|
+
hash4,
|
|
463
|
+
isValidGameId,
|
|
464
|
+
newId,
|
|
465
|
+
parseSource,
|
|
466
|
+
slug,
|
|
467
|
+
validateProject
|
|
468
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ProjectFile, Scene, AuthoringFile } from '@patterkit/model';
|
|
2
|
+
export { effectiveGameId, gameIdify, isValidGameId } from '@patterkit/model';
|
|
3
|
+
|
|
4
|
+
/** Generate a new opaque id, e.g. `newId("scn") -> "scn_8f3kq2z1"`. */
|
|
5
|
+
declare function newId(prefix?: string, length?: number): string;
|
|
6
|
+
|
|
7
|
+
/** Slugify an author name into a filename- / address-safe segment. */
|
|
8
|
+
declare function slug(name: string): string;
|
|
9
|
+
/** Deterministic 4-char base-36 hash of a string (FNV-1a 32-bit). */
|
|
10
|
+
declare function hash4(input: string): string;
|
|
11
|
+
/** Deterministic 7-char base-36 hash of a string (full FNV-1a 32-bit width) - e.g. content hashes. */
|
|
12
|
+
declare function hash32(input: string): string;
|
|
13
|
+
|
|
14
|
+
/** Parse JSON5 source text (a leading BOM is tolerated). Throws on malformed input. */
|
|
15
|
+
declare function parseSource(text: string): unknown;
|
|
16
|
+
/** Options for {@link canonicalStringify}. */
|
|
17
|
+
interface StringifyOptions {
|
|
18
|
+
/** Emit a trailing comma after the last item/entry (the canonical SOURCE form).
|
|
19
|
+
* Defaults to `true`; the compiled bundle passes `false` to stay strict JSON. */
|
|
20
|
+
trailingComma?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Serialise a value to the canonical form (UTF-8, LF, sorted keys, final newline). */
|
|
23
|
+
declare function canonicalStringify(value: unknown, opts?: StringifyOptions): string;
|
|
24
|
+
|
|
25
|
+
interface ValidationIssue {
|
|
26
|
+
code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-status-id" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field";
|
|
27
|
+
message: string;
|
|
28
|
+
/** Id of the offending node/beat, where applicable. */
|
|
29
|
+
id?: string;
|
|
30
|
+
}
|
|
31
|
+
interface ProjectInput {
|
|
32
|
+
project: ProjectFile;
|
|
33
|
+
scenes: Scene[];
|
|
34
|
+
/** Authoring shards, when loaded - status values are checked against the project ladders (spec section 13). */
|
|
35
|
+
authoring?: AuthoringFile[];
|
|
36
|
+
}
|
|
37
|
+
/** Validate a project's structural invariants. Returns an empty array when valid. */
|
|
38
|
+
declare function validateProject(input: ProjectInput): ValidationIssue[];
|
|
39
|
+
|
|
40
|
+
export { type ProjectInput, type StringifyOptions, type ValidationIssue, canonicalStringify, hash32, hash4, newId, parseSource, slug, validateProject };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ProjectFile, Scene, AuthoringFile } from '@patterkit/model';
|
|
2
|
+
export { effectiveGameId, gameIdify, isValidGameId } from '@patterkit/model';
|
|
3
|
+
|
|
4
|
+
/** Generate a new opaque id, e.g. `newId("scn") -> "scn_8f3kq2z1"`. */
|
|
5
|
+
declare function newId(prefix?: string, length?: number): string;
|
|
6
|
+
|
|
7
|
+
/** Slugify an author name into a filename- / address-safe segment. */
|
|
8
|
+
declare function slug(name: string): string;
|
|
9
|
+
/** Deterministic 4-char base-36 hash of a string (FNV-1a 32-bit). */
|
|
10
|
+
declare function hash4(input: string): string;
|
|
11
|
+
/** Deterministic 7-char base-36 hash of a string (full FNV-1a 32-bit width) - e.g. content hashes. */
|
|
12
|
+
declare function hash32(input: string): string;
|
|
13
|
+
|
|
14
|
+
/** Parse JSON5 source text (a leading BOM is tolerated). Throws on malformed input. */
|
|
15
|
+
declare function parseSource(text: string): unknown;
|
|
16
|
+
/** Options for {@link canonicalStringify}. */
|
|
17
|
+
interface StringifyOptions {
|
|
18
|
+
/** Emit a trailing comma after the last item/entry (the canonical SOURCE form).
|
|
19
|
+
* Defaults to `true`; the compiled bundle passes `false` to stay strict JSON. */
|
|
20
|
+
trailingComma?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Serialise a value to the canonical form (UTF-8, LF, sorted keys, final newline). */
|
|
23
|
+
declare function canonicalStringify(value: unknown, opts?: StringifyOptions): string;
|
|
24
|
+
|
|
25
|
+
interface ValidationIssue {
|
|
26
|
+
code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-status-id" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field";
|
|
27
|
+
message: string;
|
|
28
|
+
/** Id of the offending node/beat, where applicable. */
|
|
29
|
+
id?: string;
|
|
30
|
+
}
|
|
31
|
+
interface ProjectInput {
|
|
32
|
+
project: ProjectFile;
|
|
33
|
+
scenes: Scene[];
|
|
34
|
+
/** Authoring shards, when loaded - status values are checked against the project ladders (spec section 13). */
|
|
35
|
+
authoring?: AuthoringFile[];
|
|
36
|
+
}
|
|
37
|
+
/** Validate a project's structural invariants. Returns an empty array when valid. */
|
|
38
|
+
declare function validateProject(input: ProjectInput): ValidationIssue[];
|
|
39
|
+
|
|
40
|
+
export { type ProjectInput, type StringifyOptions, type ValidationIssue, canonicalStringify, hash32, hash4, newId, parseSource, slug, validateProject };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// src/ids.ts
|
|
2
|
+
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
3
|
+
function newId(prefix = "", length = 8) {
|
|
4
|
+
const limit = 256 - 256 % ALPHABET.length;
|
|
5
|
+
let token = "";
|
|
6
|
+
while (token.length < length) {
|
|
7
|
+
const bytes = new Uint8Array(length * 2);
|
|
8
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
9
|
+
for (const b of bytes) {
|
|
10
|
+
if (b >= limit) continue;
|
|
11
|
+
token += ALPHABET[b % ALPHABET.length];
|
|
12
|
+
if (token.length === length) break;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return prefix ? `${prefix}_${token}` : token;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/handle.ts
|
|
19
|
+
function slug(name) {
|
|
20
|
+
const s = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
21
|
+
return s || "_";
|
|
22
|
+
}
|
|
23
|
+
function fnv32(input) {
|
|
24
|
+
let h = 2166136261;
|
|
25
|
+
for (let i = 0; i < input.length; i++) {
|
|
26
|
+
h ^= input.charCodeAt(i);
|
|
27
|
+
h = Math.imul(h, 16777619);
|
|
28
|
+
}
|
|
29
|
+
return h >>> 0;
|
|
30
|
+
}
|
|
31
|
+
function hash4(input) {
|
|
32
|
+
return fnv32(input).toString(36).slice(-4).padStart(4, "0");
|
|
33
|
+
}
|
|
34
|
+
function hash32(input) {
|
|
35
|
+
return fnv32(input).toString(36).padStart(7, "0");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/index.ts
|
|
39
|
+
import { gameIdify, isValidGameId as isValidGameId2, effectiveGameId as effectiveGameId2 } from "@patterkit/model";
|
|
40
|
+
|
|
41
|
+
// src/serialize.ts
|
|
42
|
+
import JSON5 from "json5";
|
|
43
|
+
function parseSource(text) {
|
|
44
|
+
const body = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
45
|
+
return JSON5.parse(body);
|
|
46
|
+
}
|
|
47
|
+
function canonicalStringify(value, opts) {
|
|
48
|
+
return write(value, "", opts?.trailingComma ?? true) + "\n";
|
|
49
|
+
}
|
|
50
|
+
function write(v, indent, tc) {
|
|
51
|
+
if (v === null || typeof v === "boolean" || typeof v === "number") return JSON.stringify(v);
|
|
52
|
+
if (typeof v === "string") return JSON.stringify(v);
|
|
53
|
+
const tail = tc ? "," : "";
|
|
54
|
+
if (Array.isArray(v)) {
|
|
55
|
+
if (v.length === 0) return "[]";
|
|
56
|
+
const next = indent + " ";
|
|
57
|
+
const items = v.map((x) => next + write(x, next, tc));
|
|
58
|
+
return `[
|
|
59
|
+
${items.join(",\n")}${tail}
|
|
60
|
+
${indent}]`;
|
|
61
|
+
}
|
|
62
|
+
if (typeof v === "object") {
|
|
63
|
+
const obj = v;
|
|
64
|
+
const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
|
|
65
|
+
if (keys.length === 0) return "{}";
|
|
66
|
+
const next = indent + " ";
|
|
67
|
+
const entries = keys.map((k) => `${next}${JSON.stringify(k)}: ${write(obj[k], next, tc)}`);
|
|
68
|
+
return `{
|
|
69
|
+
${entries.join(",\n")}${tail}
|
|
70
|
+
${indent}}`;
|
|
71
|
+
}
|
|
72
|
+
return "null";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/validate.ts
|
|
76
|
+
import { walkNodes, DEFAULT_WRITING_STATUSES, DEFAULT_RECORDING_STATUSES, DEFAULT_DOCUMENTATION_CLASSES } from "@patterkit/model";
|
|
77
|
+
import { isValidGameId, effectiveGameId } from "@patterkit/model";
|
|
78
|
+
function choiceLoopsBack(choice, ...targets) {
|
|
79
|
+
const back = new Set(targets.filter((s) => typeof s === "string" && s.length > 0));
|
|
80
|
+
const hits = (n) => n.type === "snippet" ? !!n.jump && back.has(n.jump.to) : (n.children ?? []).some(hits);
|
|
81
|
+
return (choice.children ?? []).some(hits);
|
|
82
|
+
}
|
|
83
|
+
function isContentlessNode(node) {
|
|
84
|
+
if (node.type === "snippet") return (node.beats?.length ?? 0) === 0 && !node.jump;
|
|
85
|
+
if (node.prompt) return false;
|
|
86
|
+
return Array.isArray(node.children) && node.children.every(isContentlessNode);
|
|
87
|
+
}
|
|
88
|
+
function validateProject(input) {
|
|
89
|
+
const { project, scenes } = input;
|
|
90
|
+
const issues = [];
|
|
91
|
+
const allIds = /* @__PURE__ */ new Map();
|
|
92
|
+
const addressable = /* @__PURE__ */ new Set();
|
|
93
|
+
const sceneGameIds = /* @__PURE__ */ new Map();
|
|
94
|
+
const jumps = [];
|
|
95
|
+
const castNames = new Set((project.cast ?? []).map((c) => c.name));
|
|
96
|
+
const checkTags = (tags, where, id) => {
|
|
97
|
+
for (const t of tags ?? []) {
|
|
98
|
+
if (typeof t !== "string" || t.length === 0 || /[\s,]/.test(t)) {
|
|
99
|
+
issues.push({ code: "invalid-tag", message: `${where} has an invalid tag '${String(t)}' (no spaces or commas, and not empty)`, id });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const seeId = (id, kind, where) => {
|
|
104
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
105
|
+
issues.push({ code: "missing-id", message: `${kind} in ${where} has no id` });
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const existing = allIds.get(id);
|
|
109
|
+
if (existing !== void 0) {
|
|
110
|
+
issues.push({ code: "duplicate-id", message: `duplicate id '${id}' (${kind} and ${existing})`, id });
|
|
111
|
+
} else {
|
|
112
|
+
allIds.set(id, kind);
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
validateProjectFile(project, issues);
|
|
117
|
+
for (const scene of scenes) {
|
|
118
|
+
seeId(scene.id, "scene", "project");
|
|
119
|
+
const where = `scene '${scene.id}'`;
|
|
120
|
+
if (typeof scene.id === "string") addressable.add(scene.id);
|
|
121
|
+
if (!scene.name?.trim()) {
|
|
122
|
+
issues.push({ code: "missing-name", message: `${where} has no name`, id: scene.id });
|
|
123
|
+
}
|
|
124
|
+
if (scene.gameId && !isValidGameId(scene.gameId)) {
|
|
125
|
+
issues.push({ code: "invalid-gameid", message: `${where} address '${scene.gameId}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)`, id: scene.id });
|
|
126
|
+
}
|
|
127
|
+
const sgid = effectiveGameId(scene);
|
|
128
|
+
if (sgid) {
|
|
129
|
+
const prev = sceneGameIds.get(sgid);
|
|
130
|
+
if (prev && prev !== scene.id) issues.push({ code: "duplicate-gameid", message: `${where} address '${sgid}' is already used by scene '${prev}'`, id: scene.id });
|
|
131
|
+
else sceneGameIds.set(sgid, scene.id);
|
|
132
|
+
}
|
|
133
|
+
const blockGameIds = /* @__PURE__ */ new Map();
|
|
134
|
+
checkTags(scene.tags, where, scene.id);
|
|
135
|
+
checkDecls(scene.sceneProps, `${where} sceneProps`, issues);
|
|
136
|
+
for (const decl of scene.sceneProps ?? []) {
|
|
137
|
+
if (decl.temporary && (decl.shared ?? false)) {
|
|
138
|
+
issues.push({
|
|
139
|
+
code: "invalid-temporary",
|
|
140
|
+
message: `${where} property '${decl.name}': 'temporary' cannot be combined with 'shared'`,
|
|
141
|
+
id: scene.id
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(scene.blocks)) {
|
|
146
|
+
issues.push({ code: "malformed-node", message: `${where} has no blocks array`, id: scene.id });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (scene.blocks.length === 0) {
|
|
150
|
+
issues.push({ code: "empty-scene", message: `${where} has no blocks`, id: scene.id });
|
|
151
|
+
}
|
|
152
|
+
for (const block of scene.blocks) {
|
|
153
|
+
seeId(block.id, "block", where);
|
|
154
|
+
checkTags(block.tags, `block '${block.id}'`, block.id);
|
|
155
|
+
if (typeof block.id === "string") addressable.add(block.id);
|
|
156
|
+
if (!block.name?.trim()) {
|
|
157
|
+
issues.push({ code: "missing-name", message: `block '${block.id}' has no name`, id: block.id });
|
|
158
|
+
}
|
|
159
|
+
if (block.gameId && !isValidGameId(block.gameId)) {
|
|
160
|
+
issues.push({ code: "invalid-gameid", message: `block '${block.id}' address '${block.gameId}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)`, id: block.id });
|
|
161
|
+
}
|
|
162
|
+
const bgid = effectiveGameId(block);
|
|
163
|
+
if (bgid) {
|
|
164
|
+
const prev = blockGameIds.get(bgid);
|
|
165
|
+
if (prev && prev !== block.id) issues.push({ code: "duplicate-gameid", message: `block '${block.id}' address '${bgid}' is already used by block '${prev}' in ${where}`, id: block.id });
|
|
166
|
+
else blockGameIds.set(bgid, block.id);
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(block.children)) {
|
|
169
|
+
issues.push({ code: "malformed-node", message: `block '${block.id}' in ${where} has no children array`, id: block.id });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const inBlockOf = `block '${block.id}'`;
|
|
173
|
+
const reportEmpty = (nodes) => {
|
|
174
|
+
for (const n of nodes) {
|
|
175
|
+
if (isContentlessNode(n)) {
|
|
176
|
+
if (n.type === "group") {
|
|
177
|
+
issues.push({ code: "empty-container", message: `group '${n.id}' in ${inBlockOf} is empty`, id: n.id });
|
|
178
|
+
} else {
|
|
179
|
+
issues.push({ code: "empty-snippet", message: `snippet '${n.id}' has no beats and no jump`, id: n.id });
|
|
180
|
+
}
|
|
181
|
+
} else if (n.type === "group" && Array.isArray(n.children)) {
|
|
182
|
+
reportEmpty(n.children);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
if (block.children.length === 0 || block.children.every(isContentlessNode)) {
|
|
187
|
+
issues.push({ code: "empty-container", message: `block '${block.id}' in ${where} is empty`, id: block.id });
|
|
188
|
+
} else {
|
|
189
|
+
reportEmpty(block.children);
|
|
190
|
+
}
|
|
191
|
+
walkNodes(block.children, (node) => {
|
|
192
|
+
const inBlock = `block '${block.id}'`;
|
|
193
|
+
checkTags(node.tags, `${node.type} '${node.id}'`, node.id);
|
|
194
|
+
if (node.type === "group") {
|
|
195
|
+
seeId(node.id, "group", inBlock);
|
|
196
|
+
if (!Array.isArray(node.children)) {
|
|
197
|
+
issues.push({ code: "malformed-node", message: `group '${node.id}' in ${inBlock} has no children array`, id: node.id });
|
|
198
|
+
}
|
|
199
|
+
if (node.selector === "choice") {
|
|
200
|
+
let fallbacks = 0;
|
|
201
|
+
let hasSticky = false;
|
|
202
|
+
for (const opt of node.children ?? []) {
|
|
203
|
+
if (opt.fallback === true) fallbacks++;
|
|
204
|
+
if (opt.sticky === true) hasSticky = true;
|
|
205
|
+
if (opt.type !== "group") continue;
|
|
206
|
+
const p = opt.prompt;
|
|
207
|
+
if (!p) {
|
|
208
|
+
issues.push({ code: "missing-prompt", message: `choice option '${opt.id}' in ${inBlock} has no prompt`, id: opt.id });
|
|
209
|
+
} else {
|
|
210
|
+
if (p.kind !== "line" && p.kind !== "text") {
|
|
211
|
+
issues.push({ code: "invalid-prompt", message: `choice option '${opt.id}' prompt must be a line or text beat`, id: opt.id });
|
|
212
|
+
}
|
|
213
|
+
seeId(p.id, "prompt", `choice option '${opt.id}'`);
|
|
214
|
+
checkTags(p.tags, `prompt '${p.id}'`, p.id);
|
|
215
|
+
if (p.kind === "line" && p.character && !castNames.has(p.character)) {
|
|
216
|
+
issues.push({ code: "unknown-character", message: `prompt '${p.id}' speaker '${p.character}' is not in the project cast`, id: p.id });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (fallbacks > 1) {
|
|
221
|
+
issues.push({ code: "multiple-fallbacks", message: `choice '${node.id}' in ${inBlock} has ${fallbacks} fallback options (at most one allowed)`, id: node.id });
|
|
222
|
+
}
|
|
223
|
+
const hasUnconditionalEscape = (node.children ?? []).some((opt) => !opt.condition);
|
|
224
|
+
if ((node.children?.length ?? 0) > 0 && !hasUnconditionalEscape) {
|
|
225
|
+
issues.push({ code: "choice-can-empty", message: `choice '${node.id}' in ${inBlock} has no unconditional option or fallback - it runs dry (falls through) if every condition fails`, id: node.id });
|
|
226
|
+
} else if (!hasSticky && fallbacks === 0 && choiceLoopsBack(node, block.id, bgid, scene.id, sgid)) {
|
|
227
|
+
issues.push({ code: "choice-can-empty", message: `choice '${node.id}' in ${inBlock} can be re-entered but every option is once-only with no fallback - it will run dry`, id: node.id });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
seeId(node.id, "snippet", inBlock);
|
|
233
|
+
for (const beat of node.beats ?? []) {
|
|
234
|
+
seeId(beat.id, "beat", `snippet '${node.id}'`);
|
|
235
|
+
checkTags(beat.tags, `beat '${beat.id}'`, beat.id);
|
|
236
|
+
if (beat.kind === "line" && beat.character && !castNames.has(beat.character)) {
|
|
237
|
+
issues.push({
|
|
238
|
+
code: "unknown-character",
|
|
239
|
+
message: `beat '${beat.id}' speaker '${beat.character}' is not in the project cast`,
|
|
240
|
+
id: beat.id
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (node.jump) jumps.push({ to: node.jump.to, from: node.id });
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (input.authoring) validateAuthoring(input.authoring, project, allIds, issues);
|
|
249
|
+
for (const d of jumps) {
|
|
250
|
+
if (d.to === "END") continue;
|
|
251
|
+
if (!allIds.has(d.to)) {
|
|
252
|
+
issues.push({ code: "dangling-jump", message: `jump in '${d.from}' targets unknown id '${d.to}'`, id: d.from });
|
|
253
|
+
} else if (!addressable.has(d.to)) {
|
|
254
|
+
issues.push({
|
|
255
|
+
code: "jump-into-non-addressable",
|
|
256
|
+
message: `jump in '${d.from}' targets '${d.to}' (${allIds.get(d.to)}); jumps may only target a scene, block, or END`,
|
|
257
|
+
id: d.from
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return issues;
|
|
262
|
+
}
|
|
263
|
+
function validateProjectFile(project, issues) {
|
|
264
|
+
checkDecls(project.properties, "project properties", issues);
|
|
265
|
+
for (const decl of project.properties ?? []) {
|
|
266
|
+
if (decl.temporary) {
|
|
267
|
+
issues.push({
|
|
268
|
+
code: "invalid-temporary",
|
|
269
|
+
message: `global property '${decl.name}': 'temporary' applies only to scene-local (@scene) properties`
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (project.locales && !project.locales.all.includes(project.locales.default)) {
|
|
274
|
+
issues.push({
|
|
275
|
+
code: "invalid-locales",
|
|
276
|
+
message: `default locale '${project.locales.default}' is not in locales.all [${project.locales.all.join(", ")}]`
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const cc = project.closedCaptions;
|
|
280
|
+
if (cc && (cc.open.length === 0 || cc.close.length === 0)) {
|
|
281
|
+
issues.push({
|
|
282
|
+
code: "invalid-captions",
|
|
283
|
+
message: "closed-caption delimiters must both be non-empty"
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const writing = project.writingStatuses;
|
|
287
|
+
if (writing) {
|
|
288
|
+
checkLadderNames(writing.map((s) => s.name), "writingStatuses", issues);
|
|
289
|
+
for (const marker of ["readyToRecord", "readyToShip"]) {
|
|
290
|
+
const count = writing.filter((s) => s[marker]).length;
|
|
291
|
+
if (count !== 1) {
|
|
292
|
+
issues.push({
|
|
293
|
+
code: "invalid-status-ladder",
|
|
294
|
+
message: `writingStatuses must declare '${marker}' on exactly one status (found ${count})`
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (project.recordingStatuses) {
|
|
300
|
+
checkLadderNames(project.recordingStatuses.map((s) => s.name), "recordingStatuses", issues);
|
|
301
|
+
}
|
|
302
|
+
for (const [kind, fields] of Object.entries(project.gameDataFields ?? {})) {
|
|
303
|
+
const seen = /* @__PURE__ */ new Set();
|
|
304
|
+
for (const f of fields ?? []) {
|
|
305
|
+
const key = f.name.trim().toLowerCase();
|
|
306
|
+
if (!f.name.trim()) issues.push({ code: "invalid-gamedata-field", message: `${kind} gameData field has no name` });
|
|
307
|
+
else if (seen.has(key)) issues.push({ code: "invalid-gamedata-field", message: `${kind} gameData field '${f.name}' is declared more than once` });
|
|
308
|
+
else seen.add(key);
|
|
309
|
+
if (f.type === "enum" && !(f.values && f.values.length)) {
|
|
310
|
+
issues.push({ code: "invalid-gamedata-field", message: `${kind} gameData field '${f.name}' is an enum but lists no values` });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function validateAuthoring(files, project, allIds, issues) {
|
|
316
|
+
const writingLadder = new Set((project.writingStatuses ?? DEFAULT_WRITING_STATUSES).map((s) => s.name));
|
|
317
|
+
const recordingLadder = new Set((project.recordingStatuses ?? DEFAULT_RECORDING_STATUSES).map((s) => s.name));
|
|
318
|
+
const docClasses = new Set((project.documentationClasses ?? DEFAULT_DOCUMENTATION_CLASSES).map((c) => c.name));
|
|
319
|
+
const checkLines = (lines, where) => {
|
|
320
|
+
if (lines !== void 0 && (typeof lines !== "number" || !Number.isFinite(lines) || lines < 0)) {
|
|
321
|
+
issues.push({ code: "invalid-status-value", message: `${where}: estimate lines must be a non-negative number, got ${JSON.stringify(lines)}` });
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const est = project.estimating;
|
|
325
|
+
if (est) {
|
|
326
|
+
if (est.thresholdStatus !== void 0 && !writingLadder.has(est.thresholdStatus)) {
|
|
327
|
+
issues.push({ code: "invalid-status-value", message: `estimating: threshold status '${est.thresholdStatus}' is not in the writing ladder` });
|
|
328
|
+
}
|
|
329
|
+
checkLines(est.defaultLines, "estimating default");
|
|
330
|
+
for (const tg of est.tagEstimates ?? []) checkLines(tg.lines, `estimating tag '${tg.tag}'`);
|
|
331
|
+
}
|
|
332
|
+
for (const file of files) {
|
|
333
|
+
for (const [id, value] of Object.entries(file.writing ?? {})) {
|
|
334
|
+
if (!allIds.has(id)) issues.push({ code: "unknown-status-id", message: `writing status set on unknown id '${id}'`, id });
|
|
335
|
+
if (!writingLadder.has(value)) {
|
|
336
|
+
issues.push({ code: "invalid-status-value", message: `writing status '${value}' on '${id}' is not in the project ladder`, id });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const [id, value] of Object.entries(file.recording ?? {})) {
|
|
340
|
+
if (!allIds.has(id)) issues.push({ code: "unknown-status-id", message: `recording status set on unknown id '${id}'`, id });
|
|
341
|
+
if (!recordingLadder.has(value)) {
|
|
342
|
+
issues.push({ code: "invalid-status-value", message: `recording status '${value}' on '${id}' is not in the project ladder`, id });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const id of Object.keys(file.cut ?? {})) {
|
|
346
|
+
if (!allIds.has(id)) issues.push({ code: "unknown-status-id", message: `cut flag set on unknown id '${id}'`, id });
|
|
347
|
+
}
|
|
348
|
+
for (const [id, lines] of Object.entries(file.documentation ?? {})) {
|
|
349
|
+
if (!allIds.has(id)) issues.push({ code: "unknown-status-id", message: `documentation set on unknown id '${id}'`, id });
|
|
350
|
+
for (const line of lines) {
|
|
351
|
+
if (line.type !== void 0 && !docClasses.has(line.type)) {
|
|
352
|
+
issues.push({ code: "unknown-doc-class", message: `documentation class '${line.type}' on '${id}' is not declared (project documentationClasses)`, id });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function checkLadderNames(names, where, issues) {
|
|
359
|
+
const seen = /* @__PURE__ */ new Set();
|
|
360
|
+
for (const name of names) {
|
|
361
|
+
if (!name?.trim()) {
|
|
362
|
+
issues.push({ code: "invalid-status-ladder", message: `${where} has an empty status name` });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (seen.has(name)) {
|
|
366
|
+
issues.push({ code: "invalid-status-ladder", message: `${where} declares '${name}' more than once` });
|
|
367
|
+
}
|
|
368
|
+
seen.add(name);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function checkDecls(decls, where, issues) {
|
|
372
|
+
const seen = /* @__PURE__ */ new Set();
|
|
373
|
+
for (const decl of decls ?? []) {
|
|
374
|
+
const name = decl.name?.toLowerCase();
|
|
375
|
+
if (!name) {
|
|
376
|
+
issues.push({ code: "invalid-declaration", message: `${where}: a property declaration has no name` });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (seen.has(name)) {
|
|
380
|
+
issues.push({ code: "invalid-declaration", message: `${where}: duplicate property '${decl.name}'` });
|
|
381
|
+
}
|
|
382
|
+
seen.add(name);
|
|
383
|
+
if ((decl.type === "enum" || decl.type === "flags") && !decl.values?.length) {
|
|
384
|
+
issues.push({
|
|
385
|
+
code: "invalid-declaration",
|
|
386
|
+
message: `${where}: '${decl.name}' is ${decl.type} but declares no values`
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (decl.default !== void 0 && !defaultMatchesType(decl.default, decl)) {
|
|
390
|
+
issues.push({
|
|
391
|
+
code: "invalid-declaration",
|
|
392
|
+
message: `${where}: '${decl.name}' default ${JSON.stringify(decl.default)} does not match type '${decl.type}'`
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function defaultMatchesType(value, decl) {
|
|
398
|
+
switch (decl.type) {
|
|
399
|
+
case "boolean":
|
|
400
|
+
return typeof value === "boolean";
|
|
401
|
+
case "number":
|
|
402
|
+
return typeof value === "number";
|
|
403
|
+
case "string":
|
|
404
|
+
return typeof value === "string";
|
|
405
|
+
case "enum":
|
|
406
|
+
return typeof value === "string" && (decl.values?.includes(value) ?? true);
|
|
407
|
+
case "flags":
|
|
408
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string" && (decl.values?.includes(v) ?? true));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
export {
|
|
412
|
+
canonicalStringify,
|
|
413
|
+
effectiveGameId2 as effectiveGameId,
|
|
414
|
+
gameIdify,
|
|
415
|
+
hash32,
|
|
416
|
+
hash4,
|
|
417
|
+
isValidGameId2 as isValidGameId,
|
|
418
|
+
newId,
|
|
419
|
+
parseSource,
|
|
420
|
+
slug,
|
|
421
|
+
validateProject
|
|
422
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@patterkit/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Patter core: stable id generation, readable-handle computation, canonical (de)serialisation, and the structural validator.",
|
|
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/core" },
|
|
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
|
+
"json5": "^2.2.3"
|
|
30
|
+
}
|
|
31
|
+
}
|