@patterkit/core 0.4.1 → 0.6.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 CHANGED
@@ -167,6 +167,45 @@ function validateProject(input) {
167
167
  const sceneGameIds = /* @__PURE__ */ new Map();
168
168
  const jumps = [];
169
169
  const castNames = new Set((project.cast ?? []).map((c) => c.name));
170
+ const qualifierIds = project.qualifiers === void 0 || Array.isArray(project.qualifiers) ? new Set((0, import_model.projectQualifiers)(project).flatMap((q) => isQualifierEntry(q) && typeof q.gameId === "string" ? [q.gameId] : [])) : null;
171
+ const checkPad = (value, what, id) => {
172
+ if (value === void 0) return;
173
+ if (typeof value !== "number" || !Number.isFinite(value) || value < import_model.PAD_AFTER_MIN || value > import_model.PAD_AFTER_MAX) {
174
+ issues.push({ code: "invalid-pad", message: `${what} pause '${String(value)}' must be a number of seconds from ${import_model.PAD_AFTER_MIN} to ${import_model.PAD_AFTER_MAX}`, id });
175
+ }
176
+ };
177
+ const checkSeamPad = (beats) => {
178
+ const last = (beats ?? []).at(-1);
179
+ if (!last || last.kind !== "line" && last.kind !== "text" || typeof last.padAfter !== "number" || last.padAfter >= 0) return;
180
+ issues.push({
181
+ code: "pad-overlaps-seam",
182
+ severity: "warning",
183
+ id: last.id,
184
+ message: `beat '${last.id}' is its snippet's last line, so its negative pause can't cut in on what follows; it plays as no pause`
185
+ });
186
+ };
187
+ const checkPromptPad = (option) => {
188
+ const p = option.prompt;
189
+ if (!p || p.kind !== "line" && p.kind !== "text" || typeof p.padAfter !== "number" || p.padAfter >= 0) return;
190
+ if ((option.selector ?? "run") !== "run" || !Array.isArray(option.children)) return;
191
+ const first = option.children[0];
192
+ if (first && (first.type !== "snippet" || first.condition || (first.beats?.length ?? 0) > 0)) return;
193
+ issues.push({
194
+ code: "prompt-pad-without-beat",
195
+ severity: "warning",
196
+ id: p.id,
197
+ message: `prompt '${p.id}' has a negative pause, but its option plays nothing before it moves on, so nothing cuts in on it`
198
+ });
199
+ };
200
+ const checkQualifier = (beat, what) => {
201
+ if (beat.kind !== "line" || beat.qualifier === void 0) return;
202
+ if (qualifierIds && !qualifierIds.has(beat.qualifier)) {
203
+ issues.push({ code: "unknown-qualifier", message: `${what} '${beat.id}' qualifier '${beat.qualifier}' is not in the project's qualifiers`, id: beat.id });
204
+ }
205
+ if (!beat.character) {
206
+ issues.push({ code: "qualifier-without-speaker", severity: "warning", message: `${what} '${beat.id}' has the qualifier '${beat.qualifier}' but no speaker`, id: beat.id });
207
+ }
208
+ };
170
209
  const checkTags = (tags, where, id) => {
171
210
  for (const t of tags ?? []) {
172
211
  if (typeof t !== "string" || t.length === 0 || /[\s,]/.test(t)) {
@@ -191,6 +230,7 @@ function validateProject(input) {
191
230
  validateProjectFile(project, issues);
192
231
  for (const scene of scenes) {
193
232
  seeId(scene.id, "scene", "project");
233
+ checkPad(scene.padAfterDefault, `scene '${scene.id}' default`, scene.id);
194
234
  const where = `scene '${scene.id}'`;
195
235
  if (typeof scene.id === "string") addressable.add(scene.id);
196
236
  if (!scene.name?.trim()) {
@@ -226,6 +266,7 @@ function validateProject(input) {
226
266
  }
227
267
  for (const block of scene.blocks) {
228
268
  seeId(block.id, "block", where);
269
+ checkPad(block.padAfterDefault, `block '${block.id}' default`, block.id);
229
270
  checkTags(block.tags, `block '${block.id}'`, block.id);
230
271
  if (typeof block.id === "string") addressable.add(block.id);
231
272
  if (!block.name?.trim()) {
@@ -266,6 +307,12 @@ function validateProject(input) {
266
307
  (0, import_model.walkNodes)(block.children, (node) => {
267
308
  const inBlock = `block '${block.id}'`;
268
309
  checkTags(node.tags, `${node.type} '${node.id}'`, node.id);
310
+ checkPad(node.padAfterDefault, `${node.type} '${node.id}' default`, node.id);
311
+ if (node.type === "group" && node.prompt) checkPad(node.prompt.padAfter, `prompt '${node.prompt.id}'`, node.prompt.id);
312
+ if (node.type === "snippet") {
313
+ for (const beat of node.beats ?? []) if (beat.kind !== "gameEvent") checkPad(beat.padAfter, `beat '${beat.id}'`, beat.id);
314
+ checkSeamPad(node.beats);
315
+ }
269
316
  if (node.type === "group") {
270
317
  seeId(node.id, "group", inBlock);
271
318
  if (!Array.isArray(node.children)) {
@@ -290,6 +337,8 @@ function validateProject(input) {
290
337
  if (p.kind === "line" && p.character && !castNames.has(p.character)) {
291
338
  issues.push({ code: "unknown-character", message: `prompt '${p.id}' speaker '${p.character}' is not in the project cast`, id: p.id });
292
339
  }
340
+ checkQualifier(p, "prompt");
341
+ checkPromptPad(opt);
293
342
  }
294
343
  }
295
344
  if (fallbacks > 1) {
@@ -319,6 +368,7 @@ function validateProject(input) {
319
368
  id: beat.id
320
369
  });
321
370
  }
371
+ checkQualifier(beat, "beat");
322
372
  }
323
373
  if (node.jump) jumps.push({ to: node.jump.to, from: node.id });
324
374
  });
@@ -340,6 +390,9 @@ function validateProject(input) {
340
390
  }
341
391
  return issues;
342
392
  }
393
+ function isQualifierEntry(q) {
394
+ return typeof q === "object" && q !== null && !Array.isArray(q);
395
+ }
343
396
  function validateProjectFile(project, issues) {
344
397
  checkDecls(project.properties, "project properties", issues);
345
398
  for (const scope of project.scopeRegistry?.scopes ?? []) {
@@ -391,6 +444,30 @@ function validateProjectFile(project, issues) {
391
444
  if (project.recordingStatuses) {
392
445
  checkLadderNames(project.recordingStatuses.map((s) => s.name), "recordingStatuses", issues);
393
446
  }
447
+ const pad = project.padAfterDefault;
448
+ if (pad !== void 0 && (typeof pad !== "number" || !Number.isFinite(pad) || pad < import_model.PAD_AFTER_MIN || pad > import_model.PAD_AFTER_MAX)) {
449
+ issues.push({ code: "invalid-pad", message: `the project default pause '${String(pad)}' must be a number of seconds from ${import_model.PAD_AFTER_MIN} to ${import_model.PAD_AFTER_MAX}` });
450
+ }
451
+ const seenQualifiers = /* @__PURE__ */ new Set();
452
+ const qualifiers = project.qualifiers;
453
+ if (qualifiers !== void 0 && !Array.isArray(qualifiers)) {
454
+ issues.push({ code: "invalid-qualifier", message: "qualifiers must be a list" });
455
+ }
456
+ for (const q of Array.isArray(qualifiers) ? qualifiers : []) {
457
+ if (!isQualifierEntry(q)) {
458
+ issues.push({ code: "invalid-qualifier", message: `qualifier '${String(q)}' is not a qualifier (a name and an address)` });
459
+ continue;
460
+ }
461
+ if (typeof q.name !== "string" || !q.name.trim()) {
462
+ issues.push({ code: "invalid-qualifier", message: `qualifier '${String(q.gameId)}' has no name` });
463
+ }
464
+ if (typeof q.gameId !== "string" || !(0, import_model2.isValidGameId)(q.gameId)) {
465
+ issues.push({ code: "invalid-qualifier", message: `qualifier '${String(q.name)}' address '${String(q.gameId)}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)` });
466
+ } else if (seenQualifiers.has(q.gameId)) {
467
+ issues.push({ code: "invalid-qualifier", message: `qualifier address '${q.gameId}' is used more than once` });
468
+ }
469
+ if (typeof q.gameId === "string") seenQualifiers.add(q.gameId);
470
+ }
394
471
  for (const [kind, fields] of Object.entries(project.gameDataFields ?? {})) {
395
472
  const seen = /* @__PURE__ */ new Set();
396
473
  for (const f of fields ?? []) {
package/dist/index.d.cts CHANGED
@@ -26,7 +26,7 @@ interface StringifyOptions {
26
26
  declare function canonicalStringify(value: unknown, opts?: StringifyOptions): string;
27
27
 
28
28
  interface ValidationIssue {
29
- 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-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "jump-cycle";
29
+ 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" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "jump-cycle";
30
30
  message: string;
31
31
  /** Id of the offending node/beat, where applicable. */
32
32
  id?: string;
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@ interface StringifyOptions {
26
26
  declare function canonicalStringify(value: unknown, opts?: StringifyOptions): string;
27
27
 
28
28
  interface ValidationIssue {
29
- 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-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "jump-cycle";
29
+ 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" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "jump-cycle";
30
30
  message: string;
31
31
  /** Id of the offending node/beat, where applicable. */
32
32
  id?: string;
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ ${indent}}`;
55
55
  }
56
56
 
57
57
  // src/validate.ts
58
- import { walkNodes, DEFAULT_WRITING_STATUSES, DEFAULT_RECORDING_STATUSES, DEFAULT_DOCUMENTATION_CLASSES } from "@patterkit/model";
58
+ import { walkNodes, DEFAULT_WRITING_STATUSES, DEFAULT_RECORDING_STATUSES, DEFAULT_DOCUMENTATION_CLASSES, projectQualifiers, PAD_AFTER_MIN, PAD_AFTER_MAX } from "@patterkit/model";
59
59
  import { isValidGameId, effectiveGameId } from "@patterkit/model";
60
60
  import { isValidPropertyName, propertyNameify, RESERVED_PROPERTY_NAMES } from "@patterkit/model";
61
61
  function choiceLoopsBack(choice, ...targets) {
@@ -129,6 +129,45 @@ function validateProject(input) {
129
129
  const sceneGameIds = /* @__PURE__ */ new Map();
130
130
  const jumps = [];
131
131
  const castNames = new Set((project.cast ?? []).map((c) => c.name));
132
+ const qualifierIds = project.qualifiers === void 0 || Array.isArray(project.qualifiers) ? new Set(projectQualifiers(project).flatMap((q) => isQualifierEntry(q) && typeof q.gameId === "string" ? [q.gameId] : [])) : null;
133
+ const checkPad = (value, what, id) => {
134
+ if (value === void 0) return;
135
+ if (typeof value !== "number" || !Number.isFinite(value) || value < PAD_AFTER_MIN || value > PAD_AFTER_MAX) {
136
+ issues.push({ code: "invalid-pad", message: `${what} pause '${String(value)}' must be a number of seconds from ${PAD_AFTER_MIN} to ${PAD_AFTER_MAX}`, id });
137
+ }
138
+ };
139
+ const checkSeamPad = (beats) => {
140
+ const last = (beats ?? []).at(-1);
141
+ if (!last || last.kind !== "line" && last.kind !== "text" || typeof last.padAfter !== "number" || last.padAfter >= 0) return;
142
+ issues.push({
143
+ code: "pad-overlaps-seam",
144
+ severity: "warning",
145
+ id: last.id,
146
+ message: `beat '${last.id}' is its snippet's last line, so its negative pause can't cut in on what follows; it plays as no pause`
147
+ });
148
+ };
149
+ const checkPromptPad = (option) => {
150
+ const p = option.prompt;
151
+ if (!p || p.kind !== "line" && p.kind !== "text" || typeof p.padAfter !== "number" || p.padAfter >= 0) return;
152
+ if ((option.selector ?? "run") !== "run" || !Array.isArray(option.children)) return;
153
+ const first = option.children[0];
154
+ if (first && (first.type !== "snippet" || first.condition || (first.beats?.length ?? 0) > 0)) return;
155
+ issues.push({
156
+ code: "prompt-pad-without-beat",
157
+ severity: "warning",
158
+ id: p.id,
159
+ message: `prompt '${p.id}' has a negative pause, but its option plays nothing before it moves on, so nothing cuts in on it`
160
+ });
161
+ };
162
+ const checkQualifier = (beat, what) => {
163
+ if (beat.kind !== "line" || beat.qualifier === void 0) return;
164
+ if (qualifierIds && !qualifierIds.has(beat.qualifier)) {
165
+ issues.push({ code: "unknown-qualifier", message: `${what} '${beat.id}' qualifier '${beat.qualifier}' is not in the project's qualifiers`, id: beat.id });
166
+ }
167
+ if (!beat.character) {
168
+ issues.push({ code: "qualifier-without-speaker", severity: "warning", message: `${what} '${beat.id}' has the qualifier '${beat.qualifier}' but no speaker`, id: beat.id });
169
+ }
170
+ };
132
171
  const checkTags = (tags, where, id) => {
133
172
  for (const t of tags ?? []) {
134
173
  if (typeof t !== "string" || t.length === 0 || /[\s,]/.test(t)) {
@@ -153,6 +192,7 @@ function validateProject(input) {
153
192
  validateProjectFile(project, issues);
154
193
  for (const scene of scenes) {
155
194
  seeId(scene.id, "scene", "project");
195
+ checkPad(scene.padAfterDefault, `scene '${scene.id}' default`, scene.id);
156
196
  const where = `scene '${scene.id}'`;
157
197
  if (typeof scene.id === "string") addressable.add(scene.id);
158
198
  if (!scene.name?.trim()) {
@@ -188,6 +228,7 @@ function validateProject(input) {
188
228
  }
189
229
  for (const block of scene.blocks) {
190
230
  seeId(block.id, "block", where);
231
+ checkPad(block.padAfterDefault, `block '${block.id}' default`, block.id);
191
232
  checkTags(block.tags, `block '${block.id}'`, block.id);
192
233
  if (typeof block.id === "string") addressable.add(block.id);
193
234
  if (!block.name?.trim()) {
@@ -228,6 +269,12 @@ function validateProject(input) {
228
269
  walkNodes(block.children, (node) => {
229
270
  const inBlock = `block '${block.id}'`;
230
271
  checkTags(node.tags, `${node.type} '${node.id}'`, node.id);
272
+ checkPad(node.padAfterDefault, `${node.type} '${node.id}' default`, node.id);
273
+ if (node.type === "group" && node.prompt) checkPad(node.prompt.padAfter, `prompt '${node.prompt.id}'`, node.prompt.id);
274
+ if (node.type === "snippet") {
275
+ for (const beat of node.beats ?? []) if (beat.kind !== "gameEvent") checkPad(beat.padAfter, `beat '${beat.id}'`, beat.id);
276
+ checkSeamPad(node.beats);
277
+ }
231
278
  if (node.type === "group") {
232
279
  seeId(node.id, "group", inBlock);
233
280
  if (!Array.isArray(node.children)) {
@@ -252,6 +299,8 @@ function validateProject(input) {
252
299
  if (p.kind === "line" && p.character && !castNames.has(p.character)) {
253
300
  issues.push({ code: "unknown-character", message: `prompt '${p.id}' speaker '${p.character}' is not in the project cast`, id: p.id });
254
301
  }
302
+ checkQualifier(p, "prompt");
303
+ checkPromptPad(opt);
255
304
  }
256
305
  }
257
306
  if (fallbacks > 1) {
@@ -281,6 +330,7 @@ function validateProject(input) {
281
330
  id: beat.id
282
331
  });
283
332
  }
333
+ checkQualifier(beat, "beat");
284
334
  }
285
335
  if (node.jump) jumps.push({ to: node.jump.to, from: node.id });
286
336
  });
@@ -302,6 +352,9 @@ function validateProject(input) {
302
352
  }
303
353
  return issues;
304
354
  }
355
+ function isQualifierEntry(q) {
356
+ return typeof q === "object" && q !== null && !Array.isArray(q);
357
+ }
305
358
  function validateProjectFile(project, issues) {
306
359
  checkDecls(project.properties, "project properties", issues);
307
360
  for (const scope of project.scopeRegistry?.scopes ?? []) {
@@ -353,6 +406,30 @@ function validateProjectFile(project, issues) {
353
406
  if (project.recordingStatuses) {
354
407
  checkLadderNames(project.recordingStatuses.map((s) => s.name), "recordingStatuses", issues);
355
408
  }
409
+ const pad = project.padAfterDefault;
410
+ if (pad !== void 0 && (typeof pad !== "number" || !Number.isFinite(pad) || pad < PAD_AFTER_MIN || pad > PAD_AFTER_MAX)) {
411
+ issues.push({ code: "invalid-pad", message: `the project default pause '${String(pad)}' must be a number of seconds from ${PAD_AFTER_MIN} to ${PAD_AFTER_MAX}` });
412
+ }
413
+ const seenQualifiers = /* @__PURE__ */ new Set();
414
+ const qualifiers = project.qualifiers;
415
+ if (qualifiers !== void 0 && !Array.isArray(qualifiers)) {
416
+ issues.push({ code: "invalid-qualifier", message: "qualifiers must be a list" });
417
+ }
418
+ for (const q of Array.isArray(qualifiers) ? qualifiers : []) {
419
+ if (!isQualifierEntry(q)) {
420
+ issues.push({ code: "invalid-qualifier", message: `qualifier '${String(q)}' is not a qualifier (a name and an address)` });
421
+ continue;
422
+ }
423
+ if (typeof q.name !== "string" || !q.name.trim()) {
424
+ issues.push({ code: "invalid-qualifier", message: `qualifier '${String(q.gameId)}' has no name` });
425
+ }
426
+ if (typeof q.gameId !== "string" || !isValidGameId(q.gameId)) {
427
+ issues.push({ code: "invalid-qualifier", message: `qualifier '${String(q.name)}' address '${String(q.gameId)}' is invalid (lowercase letters, digits, hyphens; no leading/trailing hyphen)` });
428
+ } else if (seenQualifiers.has(q.gameId)) {
429
+ issues.push({ code: "invalid-qualifier", message: `qualifier address '${q.gameId}' is used more than once` });
430
+ }
431
+ if (typeof q.gameId === "string") seenQualifiers.add(q.gameId);
432
+ }
356
433
  for (const [kind, fields] of Object.entries(project.gameDataFields ?? {})) {
357
434
  const seen = /* @__PURE__ */ new Set();
358
435
  for (const f of fields ?? []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patterkit/core",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Patter core: stable id generation, readable-handle computation, canonical (de)serialisation, and the structural validator.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "build": "tsup src/index.ts --format esm,cjs --dts --clean"
35
35
  },
36
36
  "dependencies": {
37
- "@patterkit/model": "0.10.0",
37
+ "@patterkit/model": "0.12.0",
38
38
  "@wildwinter/toolkit": "^0.1.0",
39
39
  "json5": "^2.2.3"
40
40
  }