gofish-ir 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.
@@ -0,0 +1,591 @@
1
+ // <gofish-wiki> AUTO-GENERATED — see covers: in the essay; run `pnpm --filter docs sync-backlinks`
2
+ // @wiki Frontend IR — /internals/frontend/serialization
3
+ // </gofish-wiki>
4
+ /**
5
+ * Runtime validator for the GoFish frontend IR.
6
+ *
7
+ * Hand-rolled, dependency-free. Strict mode rejects unknown fields (used in
8
+ * CI and tests); the default permissive mode ignores them, suitable for
9
+ * forward-compatible reading.
10
+ */
11
+ import { COMBINATOR_MARK_TYPES, LEAF_MARK_TYPES, OPERATOR_TYPES, } from "./schema.js";
12
+ /** Validate a document against the frontend-IR schema. */
13
+ export function validate(doc, options = {}) {
14
+ const ctx = {
15
+ strict: options.strict === true,
16
+ errors: [],
17
+ };
18
+ walkDocument(doc, "$", ctx);
19
+ return ctx.errors.length === 0
20
+ ? { valid: true }
21
+ : { valid: false, errors: ctx.errors };
22
+ }
23
+ function walkDocument(node, path, ctx) {
24
+ if (!isObject(node)) {
25
+ ctx.errors.push({ path, message: "expected object" });
26
+ return;
27
+ }
28
+ expectField(node, "irVersion", path, ctx, (v, p) => {
29
+ if (v !== 0)
30
+ ctx.errors.push({
31
+ path: p,
32
+ message: `irVersion must be 0, got ${JSON.stringify(v)}`,
33
+ });
34
+ });
35
+ expectField(node, "ir", path, ctx, (v, p) => {
36
+ if (v !== "gofish-frontend")
37
+ ctx.errors.push({
38
+ path: p,
39
+ message: `ir must be "gofish-frontend", got ${JSON.stringify(v)}`,
40
+ });
41
+ });
42
+ optionalField(node, "$schema", path, ctx, expectString);
43
+ expectField(node, "root", path, ctx, walkRoot);
44
+ if (ctx.strict) {
45
+ rejectUnknown(node, ["irVersion", "ir", "$schema", "root"], path, ctx);
46
+ }
47
+ }
48
+ function walkRoot(node, path, ctx) {
49
+ if (!isObject(node)) {
50
+ ctx.errors.push({ path, message: "expected object" });
51
+ return;
52
+ }
53
+ switch (node.type) {
54
+ case "chart":
55
+ walkChart(node, path, ctx);
56
+ return;
57
+ case "layer":
58
+ walkLayer(node, path, ctx);
59
+ return;
60
+ case "raw-mark":
61
+ walkRawMark(node, path, ctx);
62
+ return;
63
+ default:
64
+ ctx.errors.push({
65
+ path: `${path}.type`,
66
+ message: `root type must be "chart" | "layer" | "raw-mark", got ${JSON.stringify(node.type)}`,
67
+ });
68
+ }
69
+ }
70
+ function walkChart(node, path, ctx) {
71
+ walkBaseFields(node, path, ctx);
72
+ optionalField(node, "data", path, ctx, (v, p) => {
73
+ if (v === null)
74
+ return;
75
+ walkData(v, p, ctx);
76
+ });
77
+ optionalField(node, "operators", path, ctx, (v, p) => walkArray(v, p, ctx, walkOperator));
78
+ expectField(node, "mark", path, ctx, walkMark);
79
+ optionalField(node, "options", path, ctx, expectObject);
80
+ optionalField(node, "zOrder", path, ctx, expectNumber);
81
+ if (ctx.strict) {
82
+ rejectUnknown(node, [
83
+ "type",
84
+ "data",
85
+ "operators",
86
+ "mark",
87
+ "options",
88
+ "zOrder",
89
+ "origin",
90
+ "meta",
91
+ ], path, ctx);
92
+ }
93
+ }
94
+ function walkLayer(node, path, ctx) {
95
+ walkBaseFields(node, path, ctx);
96
+ expectField(node, "charts", path, ctx, (v, p) => walkArray(v, p, ctx, walkRootChart));
97
+ optionalField(node, "options", path, ctx, expectObject);
98
+ if (ctx.strict) {
99
+ rejectUnknown(node, ["type", "charts", "options", "origin", "meta"], path, ctx);
100
+ }
101
+ }
102
+ function walkRootChart(node, path, ctx) {
103
+ if (!isObject(node) || node.type !== "chart") {
104
+ ctx.errors.push({
105
+ path,
106
+ message: 'layer children must be charts (type === "chart")',
107
+ });
108
+ return;
109
+ }
110
+ walkChart(node, path, ctx);
111
+ }
112
+ function walkRawMark(node, path, ctx) {
113
+ walkBaseFields(node, path, ctx);
114
+ expectField(node, "mark", path, ctx, walkMark);
115
+ optionalField(node, "options", path, ctx, expectObject);
116
+ if (ctx.strict) {
117
+ rejectUnknown(node, ["type", "mark", "options", "origin", "meta"], path, ctx);
118
+ }
119
+ }
120
+ function walkData(node, path, ctx) {
121
+ if (!isObject(node)) {
122
+ ctx.errors.push({ path, message: "expected object" });
123
+ return;
124
+ }
125
+ switch (node.type) {
126
+ case "inline":
127
+ expectField(node, "rows", path, ctx, (v, p) => {
128
+ if (!Array.isArray(v))
129
+ ctx.errors.push({ path: p, message: "rows must be an array" });
130
+ });
131
+ if (ctx.strict)
132
+ rejectUnknown(node, ["type", "rows"], path, ctx);
133
+ return;
134
+ case "select":
135
+ expectField(node, "layer", path, ctx, expectString);
136
+ if (ctx.strict)
137
+ rejectUnknown(node, ["type", "layer"], path, ctx);
138
+ return;
139
+ case "external":
140
+ optionalField(node, "id", path, ctx, expectString);
141
+ if (ctx.strict)
142
+ rejectUnknown(node, ["type", "id"], path, ctx);
143
+ return;
144
+ default:
145
+ ctx.errors.push({
146
+ path: `${path}.type`,
147
+ message: `data type must be "inline" | "select" | "external", got ${JSON.stringify(node.type)}`,
148
+ });
149
+ }
150
+ }
151
+ function walkOperator(node, path, ctx) {
152
+ if (!isObject(node)) {
153
+ ctx.errors.push({ path, message: "expected object" });
154
+ return;
155
+ }
156
+ if (typeof node.type !== "string" ||
157
+ !OPERATOR_TYPES.includes(node.type)) {
158
+ ctx.errors.push({
159
+ path: `${path}.type`,
160
+ message: `operator type must be one of ${OPERATOR_TYPES.join(", ")}, got ${JSON.stringify(node.type)}`,
161
+ });
162
+ return;
163
+ }
164
+ walkBaseFields(node, path, ctx);
165
+ // Per-type field validation. Each operator has a known set of optional
166
+ // and required fields; in strict mode, unknown fields are rejected.
167
+ const knownFields = {
168
+ derive: ["type", "lambdaId", "origin", "meta"],
169
+ spread: [
170
+ "type",
171
+ "by",
172
+ "dir",
173
+ "spacing",
174
+ "alignment",
175
+ "sharedScale",
176
+ "mode",
177
+ "reverse",
178
+ "origin",
179
+ "meta",
180
+ ],
181
+ stack: [
182
+ "type",
183
+ "by",
184
+ "dir",
185
+ "alignment",
186
+ "sharedScale",
187
+ "mode",
188
+ "reverse",
189
+ "origin",
190
+ "meta",
191
+ ],
192
+ group: ["type", "by", "origin", "meta"],
193
+ scatter: [
194
+ "type",
195
+ "by",
196
+ "x",
197
+ "y",
198
+ "xMin",
199
+ "xMax",
200
+ "yMin",
201
+ "yMax",
202
+ "alignment",
203
+ "origin",
204
+ "meta",
205
+ ],
206
+ table: ["type", "by", "spacing", "numCols", "origin", "meta"],
207
+ log: ["type", "label", "origin", "meta"],
208
+ };
209
+ switch (node.type) {
210
+ case "derive":
211
+ optionalField(node, "lambdaId", path, ctx, expectString);
212
+ break;
213
+ case "spread":
214
+ case "stack":
215
+ optionalField(node, "by", path, ctx, expectString);
216
+ optionalField(node, "dir", path, ctx, (v, p) => {
217
+ if (v !== "x" && v !== "y")
218
+ ctx.errors.push({
219
+ path: p,
220
+ message: `dir must be "x" | "y", got ${JSON.stringify(v)}`,
221
+ });
222
+ });
223
+ optionalField(node, "spacing", path, ctx, expectNumber);
224
+ optionalField(node, "alignment", path, ctx, expectString);
225
+ optionalField(node, "sharedScale", path, ctx, expectBoolean);
226
+ optionalField(node, "reverse", path, ctx, expectBoolean);
227
+ optionalField(node, "mode", path, ctx, (v, p) => {
228
+ if (v !== "edge" && v !== "center")
229
+ ctx.errors.push({
230
+ path: p,
231
+ message: `mode must be "edge" | "center"`,
232
+ });
233
+ });
234
+ break;
235
+ case "group":
236
+ expectField(node, "by", path, ctx, expectString);
237
+ break;
238
+ case "scatter":
239
+ optionalField(node, "by", path, ctx, expectString);
240
+ for (const k of ["x", "y", "xMin", "xMax", "yMin", "yMax"]) {
241
+ optionalField(node, k, path, ctx, walkChannelValue);
242
+ }
243
+ break;
244
+ case "table":
245
+ // `by` is required: the table operator can't run without an
246
+ // {x, y} field-name pair (Python's table() raises if missing).
247
+ expectField(node, "by", path, ctx, (v, p) => {
248
+ if (!isObject(v)) {
249
+ ctx.errors.push({ path: p, message: "table.by must be an object" });
250
+ return;
251
+ }
252
+ expectField(v, "x", p, ctx, expectString);
253
+ expectField(v, "y", p, ctx, expectString);
254
+ });
255
+ optionalField(node, "spacing", path, ctx, (v, p) => {
256
+ if (typeof v === "number")
257
+ return;
258
+ if (Array.isArray(v) &&
259
+ v.length === 2 &&
260
+ v.every((n) => typeof n === "number"))
261
+ return;
262
+ ctx.errors.push({
263
+ path: p,
264
+ message: "table.spacing must be a number or [number, number]",
265
+ });
266
+ });
267
+ optionalField(node, "numCols", path, ctx, expectNumber);
268
+ break;
269
+ case "log":
270
+ optionalField(node, "label", path, ctx, expectString);
271
+ break;
272
+ }
273
+ if (ctx.strict) {
274
+ rejectUnknown(node, knownFields[node.type] ?? ["type"], path, ctx);
275
+ }
276
+ }
277
+ /**
278
+ * A channel value: bare primitive, the existing `{type:"datum"}` wrapper,
279
+ * the new `{type:"field"|"literal"}` constructors, or a Python-bridge
280
+ * sentinel. Permissive — only catches obviously-wrong shapes.
281
+ */
282
+ function walkChannelValue(value, path, ctx) {
283
+ if (value === null)
284
+ return;
285
+ if (typeof value === "string")
286
+ return;
287
+ if (typeof value === "number")
288
+ return;
289
+ if (typeof value === "boolean")
290
+ return;
291
+ if (typeof value !== "object") {
292
+ ctx.errors.push({
293
+ path,
294
+ message: `channel value must be primitive or tagged object, got ${typeNameOf(value)}`,
295
+ });
296
+ return;
297
+ }
298
+ // Object form: one of the recognized tagged shapes.
299
+ const obj = value;
300
+ if ("__gofish_lambda" in obj)
301
+ return; // Python-bridge sentinel
302
+ if (obj.type === "datum")
303
+ return;
304
+ if (obj.type === "field") {
305
+ if (typeof obj.name !== "string") {
306
+ ctx.errors.push({
307
+ path: `${path}.name`,
308
+ message: 'field channel must have a string "name"',
309
+ });
310
+ }
311
+ return;
312
+ }
313
+ if (obj.type === "literal") {
314
+ if (!("value" in obj)) {
315
+ ctx.errors.push({
316
+ path: `${path}.value`,
317
+ message: 'literal channel must have a "value" field',
318
+ });
319
+ }
320
+ return;
321
+ }
322
+ // Permissive fallback: allow unknown object shapes for forward-compat.
323
+ }
324
+ function walkMark(node, path, ctx) {
325
+ if (!isObject(node)) {
326
+ ctx.errors.push({ path, message: "expected object" });
327
+ return;
328
+ }
329
+ const t = node.type;
330
+ if (typeof t !== "string") {
331
+ ctx.errors.push({
332
+ path: `${path}.type`,
333
+ message: "mark type must be a string",
334
+ });
335
+ return;
336
+ }
337
+ if (t === "ref") {
338
+ walkRefMark(node, path, ctx);
339
+ return;
340
+ }
341
+ if (node.__combinator === true) {
342
+ walkCombinatorMark(node, path, ctx);
343
+ return;
344
+ }
345
+ if (LEAF_MARK_TYPES.includes(t)) {
346
+ walkLeafMark(node, path, ctx);
347
+ return;
348
+ }
349
+ ctx.errors.push({
350
+ path: `${path}.type`,
351
+ message: `unrecognized mark type ${JSON.stringify(t)}`,
352
+ });
353
+ }
354
+ function walkRefMark(node, path, ctx) {
355
+ walkBaseFields(node, path, ctx);
356
+ expectField(node, "selection", path, ctx, (v, p) => {
357
+ if (typeof v !== "string" && !Array.isArray(v)) {
358
+ ctx.errors.push({
359
+ path: p,
360
+ message: "ref.selection must be a string or array",
361
+ });
362
+ }
363
+ });
364
+ optionalField(node, "name", path, ctx, expectNameOrToken);
365
+ optionalField(node, "label", path, ctx, walkLabel);
366
+ optionalField(node, "zOrder", path, ctx, expectNumber);
367
+ if (ctx.strict) {
368
+ rejectUnknown(node, ["type", "selection", "name", "label", "zOrder", "origin", "meta"], path, ctx);
369
+ }
370
+ }
371
+ function walkCombinatorMark(node, path, ctx) {
372
+ if (!COMBINATOR_MARK_TYPES.includes(node.type)) {
373
+ ctx.errors.push({
374
+ path: `${path}.type`,
375
+ message: `combinator mark type must be one of ${COMBINATOR_MARK_TYPES.join(", ")}`,
376
+ });
377
+ }
378
+ walkBaseFields(node, path, ctx);
379
+ optionalField(node, "options", path, ctx, expectObject);
380
+ expectField(node, "children", path, ctx, (v, p) => walkArray(v, p, ctx, walkMark));
381
+ optionalField(node, "name", path, ctx, expectNameOrToken);
382
+ optionalField(node, "label", path, ctx, walkLabel);
383
+ optionalField(node, "constraints", path, ctx, (v, p) => walkArray(v, p, ctx, walkConstraint));
384
+ optionalField(node, "zOrder", path, ctx, expectNumber);
385
+ if (ctx.strict) {
386
+ rejectUnknown(node, [
387
+ "type",
388
+ "__combinator",
389
+ "options",
390
+ "children",
391
+ "name",
392
+ "label",
393
+ "constraints",
394
+ "zOrder",
395
+ "origin",
396
+ "meta",
397
+ ], path, ctx);
398
+ }
399
+ }
400
+ function walkLeafMark(node, path, ctx) {
401
+ walkBaseFields(node, path, ctx);
402
+ optionalField(node, "name", path, ctx, expectNameOrToken);
403
+ optionalField(node, "label", path, ctx, walkLabel);
404
+ optionalField(node, "constraints", path, ctx, (v, p) => walkArray(v, p, ctx, walkConstraint));
405
+ optionalField(node, "zOrder", path, ctx, expectNumber);
406
+ // Channel-valued props are unrestricted in v0 (mirrors widget IR).
407
+ // Strict mode does NOT reject unknown fields on leaf marks, because the
408
+ // entire point of a leaf is to carry channel-valued props with arbitrary
409
+ // names (h, w, fill, x, y, etc.).
410
+ }
411
+ function walkLabel(node, path, ctx) {
412
+ // Shorthand forms (matching the JS API):
413
+ // label: true → "label with default settings"
414
+ // label: "field" → "label with this field accessor, defaults elsewhere"
415
+ if (typeof node === "boolean")
416
+ return;
417
+ if (typeof node === "string")
418
+ return;
419
+ if (!isObject(node)) {
420
+ ctx.errors.push({
421
+ path,
422
+ message: "expected object, boolean, or string",
423
+ });
424
+ return;
425
+ }
426
+ expectField(node, "accessor", path, ctx, expectString);
427
+ optionalField(node, "position", path, ctx, expectString);
428
+ optionalField(node, "fontSize", path, ctx, expectNumber);
429
+ optionalField(node, "color", path, ctx, expectString);
430
+ optionalField(node, "offset", path, ctx, expectNumber);
431
+ optionalField(node, "minSpace", path, ctx, expectNumber);
432
+ optionalField(node, "rotate", path, ctx, expectNumber);
433
+ if (ctx.strict) {
434
+ rejectUnknown(node, [
435
+ "accessor",
436
+ "position",
437
+ "fontSize",
438
+ "color",
439
+ "offset",
440
+ "minSpace",
441
+ "rotate",
442
+ ], path, ctx);
443
+ }
444
+ }
445
+ function walkConstraint(node, path, ctx) {
446
+ if (!isObject(node)) {
447
+ ctx.errors.push({ path, message: "expected object" });
448
+ return;
449
+ }
450
+ const t = node.type;
451
+ if (t !== "align" && t !== "distribute" && t !== "zAbove" && t !== "zBelow") {
452
+ ctx.errors.push({
453
+ path: `${path}.type`,
454
+ message: `constraint type must be "align" | "distribute" | "zAbove" | "zBelow"`,
455
+ });
456
+ return;
457
+ }
458
+ expectField(node, "refs", path, ctx, (v, p) => {
459
+ if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) {
460
+ ctx.errors.push({ path: p, message: "refs must be an array of strings" });
461
+ }
462
+ });
463
+ optionalField(node, "options", path, ctx, expectObject);
464
+ if (ctx.strict) {
465
+ rejectUnknown(node, ["type", "refs", "options"], path, ctx);
466
+ }
467
+ }
468
+ function walkBaseFields(node, path, ctx) {
469
+ optionalField(node, "origin", path, ctx, walkOrigin);
470
+ optionalField(node, "meta", path, ctx, walkMeta);
471
+ }
472
+ function walkOrigin(node, path, ctx) {
473
+ if (!isObject(node)) {
474
+ ctx.errors.push({ path, message: "origin must be an object" });
475
+ return;
476
+ }
477
+ optionalField(node, "name", path, ctx, expectNameOrToken);
478
+ optionalField(node, "stack", path, ctx, expectString);
479
+ if (ctx.strict)
480
+ rejectUnknown(node, ["name", "stack"], path, ctx);
481
+ }
482
+ function walkMeta(node, path, _ctx) {
483
+ if (!isObject(node)) {
484
+ _ctx.errors.push({ path, message: "meta must be an object" });
485
+ return;
486
+ }
487
+ // Meta is intentionally open — additional keys are reserved for future
488
+ // passes. v0 doesn't enforce per-key shapes; that lands with the passes
489
+ // that populate them.
490
+ }
491
+ // ---------------------------------------------------------------------------
492
+ // Field helpers
493
+ // ---------------------------------------------------------------------------
494
+ function expectField(obj, key, parentPath, ctx, check) {
495
+ const childPath = `${parentPath}.${key}`;
496
+ if (!(key in obj)) {
497
+ ctx.errors.push({
498
+ path: childPath,
499
+ message: `required field "${key}" is missing`,
500
+ });
501
+ return;
502
+ }
503
+ check(obj[key], childPath, ctx);
504
+ }
505
+ function optionalField(obj, key, parentPath, ctx, check) {
506
+ // Treat both `undefined` and explicit `null` as "absent". Python's
507
+ // `to_dict()` emits `null` for several optional fields (data, zOrder,
508
+ // ...) rather than omitting them.
509
+ if (!(key in obj) || obj[key] === undefined || obj[key] === null)
510
+ return;
511
+ check(obj[key], `${parentPath}.${key}`, ctx);
512
+ }
513
+ function walkArray(v, path, ctx, walkItem) {
514
+ if (!Array.isArray(v)) {
515
+ ctx.errors.push({ path, message: "expected array" });
516
+ return;
517
+ }
518
+ v.forEach((item, i) => walkItem(item, `${path}[${i}]`, ctx));
519
+ }
520
+ function rejectUnknown(obj, knownKeys, path, ctx) {
521
+ for (const k of Object.keys(obj)) {
522
+ if (!knownKeys.includes(k)) {
523
+ ctx.errors.push({
524
+ path: `${path}.${k}`,
525
+ message: `unknown field "${k}" (strict)`,
526
+ });
527
+ }
528
+ }
529
+ }
530
+ function expectString(value, path, ctx) {
531
+ if (typeof value !== "string") {
532
+ ctx.errors.push({
533
+ path,
534
+ message: `expected string, got ${typeNameOf(value)}`,
535
+ });
536
+ }
537
+ }
538
+ function expectNumber(value, path, ctx) {
539
+ if (typeof value !== "number") {
540
+ ctx.errors.push({
541
+ path,
542
+ message: `expected number, got ${typeNameOf(value)}`,
543
+ });
544
+ }
545
+ }
546
+ function expectBoolean(value, path, ctx) {
547
+ if (typeof value !== "boolean") {
548
+ ctx.errors.push({
549
+ path,
550
+ message: `expected boolean, got ${typeNameOf(value)}`,
551
+ });
552
+ }
553
+ }
554
+ /**
555
+ * A name field may be a string or a Python-bridge token sentinel
556
+ * (`{__gofish_token, __tag}`) used to encode hygienic names across the
557
+ * widget bridge. The deserializer resolves the sentinel to a runtime
558
+ * Token at chart-build time.
559
+ */
560
+ function expectNameOrToken(value, path, ctx) {
561
+ if (typeof value === "string")
562
+ return;
563
+ if (typeof value === "object" &&
564
+ value !== null &&
565
+ typeof value.__gofish_token === "string" &&
566
+ typeof value.__tag === "string") {
567
+ return;
568
+ }
569
+ ctx.errors.push({
570
+ path,
571
+ message: `expected string or token sentinel, got ${typeNameOf(value)}`,
572
+ });
573
+ }
574
+ function expectObject(value, path, ctx) {
575
+ if (!isObject(value)) {
576
+ ctx.errors.push({
577
+ path,
578
+ message: `expected object, got ${typeNameOf(value)}`,
579
+ });
580
+ }
581
+ }
582
+ function isObject(value) {
583
+ return typeof value === "object" && value !== null && !Array.isArray(value);
584
+ }
585
+ function typeNameOf(value) {
586
+ if (value === null)
587
+ return "null";
588
+ if (Array.isArray(value))
589
+ return "array";
590
+ return typeof value;
591
+ }
@@ -0,0 +1,2 @@
1
+ export * as Frontend from "./frontend/index.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * as Frontend from "./frontend/index.js";
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "gofish-ir",
3
+ "version": "0.1.0",
4
+ "description": "Serialized intermediate representation (IR) for GoFish chart specifications.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./frontend": {
15
+ "import": "./dist/frontend/index.js",
16
+ "types": "./dist/frontend/index.d.ts"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "gofish",
25
+ "ir",
26
+ "schema",
27
+ "visualization"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/gofish-graphics/gofish-graphics"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc && tsx scripts/emit-jsonschema.ts",
36
+ "test": "tsx src/tests/schema.test.ts",
37
+ "prepublishOnly": "pnpm run build"
38
+ },
39
+ "devDependencies": {
40
+ "tsx": "^4.19.2",
41
+ "typescript": "^5.7.2"
42
+ }
43
+ }