route-graphics 1.12.3 → 1.14.1

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/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "route-graphics",
3
- "version": "1.12.3",
3
+ "version": "1.14.1",
4
4
  "description": "A 2D graphics rendering interface that takes JSON input and renders pixels using PixiJS",
5
5
  "main": "dist/RouteGraphics.js",
6
6
  "type": "module",
7
+ "bin": {
8
+ "route-graphics": "./bin/route-graphics.js"
9
+ },
7
10
  "license": "MIT",
8
11
  "repository": {
9
12
  "type": "git",
@@ -22,7 +25,9 @@
22
25
  "graphics-library"
23
26
  ],
24
27
  "files": [
28
+ "bin",
25
29
  "dist",
30
+ "src/cli",
26
31
  "package.json"
27
32
  ],
28
33
  "scripts": {
@@ -45,18 +50,18 @@
45
50
  "dependencies": {
46
51
  "@pixi/unsafe-eval": "^7.4.3",
47
52
  "hotkeys-js": "^4.0.0-beta.7",
53
+ "js-yaml": "^4.1.0",
54
+ "playwright": "^1.44.0",
48
55
  "pixi.js": "^8.7.1"
49
56
  },
50
57
  "devDependencies": {
51
58
  "@vitest/coverage-v8": "^4.0.8",
52
59
  "esbuild": "^0.25.6",
53
60
  "husky": "^9.1.7",
54
- "js-yaml": "^4.1.0",
55
61
  "jsdom": "^27.0.1",
56
62
  "lint-staged": "^16.2.6",
57
63
  "oxlint": "^1.26.0",
58
64
  "pixelmatch": "^5.3.0",
59
- "playwright": "^1.44.0",
60
65
  "pngjs": "^7.0.0",
61
66
  "puty": "^0.1.2",
62
67
  "vitest": "^4.0.8"
@@ -0,0 +1,5 @@
1
+ export const getRendererBrowserLaunchOptions = (browserExecutablePath) => ({
2
+ headless: true,
3
+ executablePath: browserExecutablePath,
4
+ args: ["--autoplay-policy=no-user-gesture-required"],
5
+ });
@@ -0,0 +1,512 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import yaml from "js-yaml";
5
+
6
+ const IMAGE_MIME_TYPES = new Map([
7
+ [".apng", "image/apng"],
8
+ [".avif", "image/avif"],
9
+ [".gif", "image/gif"],
10
+ [".jpeg", "image/jpeg"],
11
+ [".jpg", "image/jpeg"],
12
+ [".png", "image/png"],
13
+ [".svg", "image/svg+xml"],
14
+ [".webp", "image/webp"],
15
+ ]);
16
+
17
+ const AUDIO_MIME_TYPES = new Map([
18
+ [".aac", "audio/aac"],
19
+ [".flac", "audio/flac"],
20
+ [".m4a", "audio/mp4"],
21
+ [".mp3", "audio/mpeg"],
22
+ [".oga", "audio/ogg"],
23
+ [".ogg", "audio/ogg"],
24
+ [".wav", "audio/wav"],
25
+ ]);
26
+
27
+ const VIDEO_MIME_TYPES = new Map([
28
+ [".mov", "video/quicktime"],
29
+ [".mp4", "video/mp4"],
30
+ [".ogv", "video/ogg"],
31
+ [".webm", "video/webm"],
32
+ ]);
33
+
34
+ const FONT_MIME_TYPES = new Map([
35
+ [".otf", "font/otf"],
36
+ [".ttf", "font/ttf"],
37
+ [".woff", "font/woff"],
38
+ [".woff2", "font/woff2"],
39
+ ]);
40
+
41
+ const CONFIG_ONLY_KEYS = new Set([
42
+ "assets",
43
+ "backgroundColor",
44
+ "height",
45
+ "state",
46
+ "states",
47
+ "width",
48
+ ]);
49
+
50
+ const DIRECT_ASSET_KEYS = new Set([
51
+ "barSrc",
52
+ "hoverSrc",
53
+ "inactiveBarSrc",
54
+ "pressSrc",
55
+ "soundSrc",
56
+ "src",
57
+ "thumbSrc",
58
+ ]);
59
+
60
+ const FONT_ASSET_KEYS = new Set(["fontFamily"]);
61
+
62
+ const NON_RENDER_BRANCH_KEYS = new Set(["payload"]);
63
+
64
+ const FIXED_ASSET_FALLBACK_TYPES = new Map([
65
+ ["barSrc", "image/png"],
66
+ ["hoverSrc", "image/png"],
67
+ ["inactiveBarSrc", "image/png"],
68
+ ["pressSrc", "image/png"],
69
+ ["soundSrc", "audio/mpeg"],
70
+ ["thumbSrc", "image/png"],
71
+ ]);
72
+
73
+ const isPlainObject = (value) => {
74
+ return value !== null && typeof value === "object" && !Array.isArray(value);
75
+ };
76
+
77
+ const getPathExtension = (value) => {
78
+ if (typeof value !== "string") return "";
79
+
80
+ const normalized = value.split("?")[0].split("#")[0];
81
+
82
+ return path.extname(normalized).toLowerCase();
83
+ };
84
+
85
+ const parseFileUrl = (value) => {
86
+ try {
87
+ return fileURLToPath(value);
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+
93
+ const isRemoteAssetUrl = (value) => {
94
+ if (typeof value !== "string") return false;
95
+
96
+ return /^(https?:)?\/\//i.test(value) || value.startsWith("data:");
97
+ };
98
+
99
+ const isFileUrl = (value) => {
100
+ return typeof value === "string" && value.startsWith("file:");
101
+ };
102
+
103
+ const isAbsoluteWindowsPath = (value) => {
104
+ return typeof value === "string" && /^[a-zA-Z]:[\\/]/.test(value);
105
+ };
106
+
107
+ const looksLikeLocalAssetPath = (value) => {
108
+ if (typeof value !== "string" || value.length === 0) return false;
109
+
110
+ if (isRemoteAssetUrl(value) || isFileUrl(value)) {
111
+ return true;
112
+ }
113
+
114
+ if (
115
+ path.isAbsolute(value) ||
116
+ isAbsoluteWindowsPath(value) ||
117
+ value.startsWith("./") ||
118
+ value.startsWith("../") ||
119
+ value.startsWith("/") ||
120
+ value.includes("/") ||
121
+ value.includes("\\")
122
+ ) {
123
+ return true;
124
+ }
125
+
126
+ return (
127
+ IMAGE_MIME_TYPES.has(getPathExtension(value)) ||
128
+ AUDIO_MIME_TYPES.has(getPathExtension(value)) ||
129
+ VIDEO_MIME_TYPES.has(getPathExtension(value)) ||
130
+ FONT_MIME_TYPES.has(getPathExtension(value))
131
+ );
132
+ };
133
+
134
+ const getNextNodePath = (currentPath, key) => {
135
+ if (typeof key === "number") {
136
+ return `${currentPath}[${key}]`;
137
+ }
138
+
139
+ return `${currentPath}.${key}`;
140
+ };
141
+
142
+ const detectMimeType = (assetPath) => {
143
+ const extension = getPathExtension(assetPath);
144
+
145
+ if (IMAGE_MIME_TYPES.has(extension)) {
146
+ return IMAGE_MIME_TYPES.get(extension);
147
+ }
148
+
149
+ if (AUDIO_MIME_TYPES.has(extension)) {
150
+ return AUDIO_MIME_TYPES.get(extension);
151
+ }
152
+
153
+ if (VIDEO_MIME_TYPES.has(extension)) {
154
+ return VIDEO_MIME_TYPES.get(extension);
155
+ }
156
+
157
+ if (FONT_MIME_TYPES.has(extension)) {
158
+ return FONT_MIME_TYPES.get(extension);
159
+ }
160
+
161
+ return undefined;
162
+ };
163
+
164
+ const inferSrcFallbackType = ({ node, ancestry }) => {
165
+ const typedNodes = [node, ...ancestry.slice().reverse()];
166
+
167
+ for (const typedNode of typedNodes) {
168
+ if (!isPlainObject(typedNode) || typeof typedNode.type !== "string") {
169
+ continue;
170
+ }
171
+
172
+ if (typedNode.type === "sound") {
173
+ return "audio/mpeg";
174
+ }
175
+
176
+ if (typedNode.type === "video") {
177
+ return "video/mp4";
178
+ }
179
+ }
180
+
181
+ return "image/png";
182
+ };
183
+
184
+ const getRequiredAssetFallbackType = ({ key, node, ancestry }) => {
185
+ if (key === "src") {
186
+ return inferSrcFallbackType({
187
+ node,
188
+ ancestry,
189
+ });
190
+ }
191
+
192
+ return FIXED_ASSET_FALLBACK_TYPES.get(key) ?? "image/png";
193
+ };
194
+
195
+ const getAssetSourceValue = (rawValue) => {
196
+ if (typeof rawValue === "string") {
197
+ return rawValue;
198
+ }
199
+
200
+ if (!isPlainObject(rawValue)) {
201
+ return undefined;
202
+ }
203
+
204
+ return rawValue.path ?? rawValue.url ?? rawValue.src;
205
+ };
206
+
207
+ const getExplicitAssetType = (rawValue) => {
208
+ if (!isPlainObject(rawValue)) {
209
+ return undefined;
210
+ }
211
+
212
+ return typeof rawValue.type === "string" && rawValue.type.length > 0
213
+ ? rawValue.type
214
+ : undefined;
215
+ };
216
+
217
+ const collectAssetReferences = ({ states, assetAliases }) => {
218
+ const references = new Map();
219
+
220
+ const registerReference = ({ alias, fallbackType, nodePath }) => {
221
+ const record = references.get(alias);
222
+
223
+ if (!record) {
224
+ references.set(alias, {
225
+ fallbackTypes: new Set([fallbackType]),
226
+ nodePaths: [nodePath],
227
+ });
228
+ return;
229
+ }
230
+
231
+ record.fallbackTypes.add(fallbackType);
232
+ record.nodePaths.push(nodePath);
233
+ };
234
+
235
+ const walk = (node, nodePath, ancestry = []) => {
236
+ if (!isPlainObject(node) && !Array.isArray(node)) {
237
+ return;
238
+ }
239
+
240
+ if (Array.isArray(node)) {
241
+ node.forEach((value, index) => {
242
+ walk(value, getNextNodePath(nodePath, index), ancestry);
243
+ });
244
+ return;
245
+ }
246
+
247
+ for (const [key, value] of Object.entries(node)) {
248
+ const nextPath = getNextNodePath(nodePath, key);
249
+
250
+ if (NON_RENDER_BRANCH_KEYS.has(key)) {
251
+ continue;
252
+ }
253
+
254
+ if (DIRECT_ASSET_KEYS.has(key) && typeof value === "string") {
255
+ if (assetAliases.has(value)) {
256
+ registerReference({
257
+ alias: value,
258
+ fallbackType: getRequiredAssetFallbackType({
259
+ key,
260
+ node,
261
+ ancestry,
262
+ }),
263
+ nodePath: nextPath,
264
+ });
265
+ } else if (looksLikeLocalAssetPath(value)) {
266
+ throw new Error(
267
+ `Direct asset references are not supported. Define "${value}" in top-level assets and reference its alias instead (at ${nextPath}).`,
268
+ );
269
+ } else {
270
+ throw new Error(
271
+ `Asset alias "${value}" referenced at ${nextPath} is not defined in top-level assets.`,
272
+ );
273
+ }
274
+ }
275
+
276
+ if (
277
+ FONT_ASSET_KEYS.has(key) &&
278
+ typeof value === "string" &&
279
+ assetAliases.has(value)
280
+ ) {
281
+ registerReference({
282
+ alias: value,
283
+ fallbackType: "font/ttf",
284
+ nodePath: nextPath,
285
+ });
286
+ }
287
+
288
+ if (Array.isArray(value) || isPlainObject(value)) {
289
+ walk(value, nextPath, [...ancestry, node]);
290
+ }
291
+ }
292
+ };
293
+
294
+ walk(states, "states");
295
+
296
+ return references;
297
+ };
298
+
299
+ const inferMimeType = (assetPath, fallbackType = "image/png") => {
300
+ return detectMimeType(assetPath) ?? fallbackType;
301
+ };
302
+
303
+ const normalizeState = (value, index = 0) => {
304
+ if (!isPlainObject(value)) {
305
+ throw new Error(`State at index ${index} must be a YAML object.`);
306
+ }
307
+
308
+ return {
309
+ ...value,
310
+ id: value.id ?? `state-${index}`,
311
+ elements: Array.isArray(value.elements) ? value.elements : [],
312
+ animations: Array.isArray(value.animations) ? value.animations : [],
313
+ audio: Array.isArray(value.audio) ? value.audio : [],
314
+ global: isPlainObject(value.global) ? value.global : {},
315
+ };
316
+ };
317
+
318
+ const extractStateFromConfigObject = (value) => {
319
+ const state = {};
320
+
321
+ for (const [key, entryValue] of Object.entries(value)) {
322
+ if (CONFIG_ONLY_KEYS.has(key)) continue;
323
+ state[key] = entryValue;
324
+ }
325
+
326
+ return state;
327
+ };
328
+
329
+ const normalizeTopLevelDocument = (document) => {
330
+ if (Array.isArray(document)) {
331
+ return {
332
+ width: undefined,
333
+ height: undefined,
334
+ backgroundColor: undefined,
335
+ assets: {},
336
+ states: document.map((state, index) => normalizeState(state, index)),
337
+ };
338
+ }
339
+
340
+ if (!isPlainObject(document)) {
341
+ throw new Error(
342
+ "YAML root must be an object, an array of states, or a multi-document state list.",
343
+ );
344
+ }
345
+
346
+ const { width, height, backgroundColor, assets, state, states } = document;
347
+
348
+ if (Array.isArray(states)) {
349
+ return {
350
+ width,
351
+ height,
352
+ backgroundColor,
353
+ assets: isPlainObject(assets) ? assets : {},
354
+ states: states.map((entry, index) => normalizeState(entry, index)),
355
+ };
356
+ }
357
+
358
+ if (isPlainObject(state)) {
359
+ return {
360
+ width,
361
+ height,
362
+ backgroundColor,
363
+ assets: isPlainObject(assets) ? assets : {},
364
+ states: [normalizeState(state, 0)],
365
+ };
366
+ }
367
+
368
+ return {
369
+ width,
370
+ height,
371
+ backgroundColor,
372
+ assets: isPlainObject(assets) ? assets : {},
373
+ states: [normalizeState(extractStateFromConfigObject(document), 0)],
374
+ };
375
+ };
376
+
377
+ const loadRenderDefinition = (yamlSource) => {
378
+ const documents = [];
379
+ yaml.loadAll(yamlSource, (document) => {
380
+ if (document !== undefined && document !== null) {
381
+ documents.push(document);
382
+ }
383
+ });
384
+
385
+ if (documents.length === 0) {
386
+ throw new Error("YAML file did not contain any documents.");
387
+ }
388
+
389
+ if (documents.length === 1) {
390
+ return normalizeTopLevelDocument(documents[0]);
391
+ }
392
+
393
+ return {
394
+ width: undefined,
395
+ height: undefined,
396
+ backgroundColor: undefined,
397
+ assets: {},
398
+ states: documents.map((document, index) => normalizeState(document, index)),
399
+ };
400
+ };
401
+
402
+ const normalizeAssetConfigEntry = (rawValue, fallbackType, baseDir) => {
403
+ if (typeof rawValue === "string") {
404
+ const type = inferMimeType(rawValue, fallbackType);
405
+
406
+ if (isRemoteAssetUrl(rawValue)) {
407
+ return {
408
+ type,
409
+ kind: "remote",
410
+ url: rawValue,
411
+ };
412
+ }
413
+
414
+ if (isFileUrl(rawValue)) {
415
+ return {
416
+ type,
417
+ kind: "local",
418
+ path: parseFileUrl(rawValue),
419
+ };
420
+ }
421
+
422
+ return {
423
+ type,
424
+ kind: "local",
425
+ path: path.resolve(baseDir, rawValue),
426
+ };
427
+ }
428
+
429
+ if (!isPlainObject(rawValue)) {
430
+ throw new Error("Asset entries must be a string or an object.");
431
+ }
432
+
433
+ const sourceValue = rawValue.path ?? rawValue.url ?? rawValue.src;
434
+
435
+ if (typeof sourceValue !== "string" || sourceValue.length === 0) {
436
+ throw new Error("Asset object entries must provide path, url, or src.");
437
+ }
438
+
439
+ return normalizeAssetConfigEntry(
440
+ sourceValue,
441
+ rawValue.type ?? fallbackType,
442
+ baseDir,
443
+ );
444
+ };
445
+
446
+ const collectAssetDefinitions = ({ assets = {}, states = [], baseDir }) => {
447
+ const assetAliases = new Set(Object.keys(assets));
448
+ const references = collectAssetReferences({
449
+ states,
450
+ assetAliases,
451
+ });
452
+ const definitions = {};
453
+
454
+ for (const [key, reference] of references.entries()) {
455
+ const rawValue = assets[key];
456
+ const explicitType = getExplicitAssetType(rawValue);
457
+ const sourceValue = getAssetSourceValue(rawValue);
458
+ const detectedType = sourceValue ? detectMimeType(sourceValue) : undefined;
459
+ const fallbackTypes = [...reference.fallbackTypes];
460
+
461
+ if (fallbackTypes.length > 1 && !explicitType && !detectedType) {
462
+ throw new Error(
463
+ `Asset alias "${key}" is referenced as multiple asset types (${fallbackTypes.join(", ")} at ${reference.nodePaths.join(", ")}). Define assets.${key}.type explicitly.`,
464
+ );
465
+ }
466
+
467
+ definitions[key] = normalizeAssetConfigEntry(
468
+ rawValue,
469
+ fallbackTypes[0],
470
+ baseDir,
471
+ );
472
+ }
473
+
474
+ return definitions;
475
+ };
476
+
477
+ const parseBackgroundColor = (value, fallback = 0x000000) => {
478
+ if (value === undefined || value === null || value === "") {
479
+ return fallback;
480
+ }
481
+
482
+ if (typeof value === "number" && Number.isFinite(value)) {
483
+ return value;
484
+ }
485
+
486
+ if (typeof value !== "string") {
487
+ throw new Error("Background color must be a number or a string.");
488
+ }
489
+
490
+ const trimmed = value.trim();
491
+
492
+ if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
493
+ return Number.parseInt(trimmed.slice(1), 16);
494
+ }
495
+
496
+ if (/^0x[0-9a-fA-F]{6}$/.test(trimmed)) {
497
+ return Number.parseInt(trimmed.slice(2), 16);
498
+ }
499
+
500
+ if (/^[0-9]+$/.test(trimmed)) {
501
+ return Number.parseInt(trimmed, 10);
502
+ }
503
+
504
+ throw new Error(`Unsupported background color format: ${value}`);
505
+ };
506
+
507
+ export {
508
+ collectAssetDefinitions,
509
+ inferMimeType,
510
+ loadRenderDefinition,
511
+ parseBackgroundColor,
512
+ };