@shayc/open-board-format 1.3.4 → 1.3.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @shayc/open-board-format
2
2
 
3
+ ## 1.3.5
4
+
5
+ ### Patch Changes
6
+
7
+ - cac6729: Polish public documentation and JSDoc for consistent terminology, clearer API boundaries, and faster scanning.
8
+
3
9
  ## 1.3.4
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -3,46 +3,46 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@shayc/open-board-format)](https://www.npmjs.com/package/@shayc/open-board-format)
4
4
  [![CI](https://github.com/shayc/open-board-format/actions/workflows/ci.yml/badge.svg)](https://github.com/shayc/open-board-format/actions/workflows/ci.yml)
5
5
 
6
- A TypeScript/JavaScript library for parsing, validating, and creating [Open Board Format](https://www.openboardformat.org/) (OBF) communication boards (`.obf`) and archives (`.obz`) for AAC applications.
6
+ A TypeScript library for parsing, validating, and creating [Open Board Format](https://www.openboardformat.org/) communication boards for AAC applications.
7
7
 
8
- Add Open Board Format import and export without implementing schemas, manifests, or archive handling yourself.
8
+ Add Open Board Format support without implementing schemas or archive handling yourself.
9
9
 
10
10
  ## Features
11
11
 
12
- - Load OBF or OBZ through one byte-based format detection API.
12
+ - Load OBF or OBZ files through a single API.
13
13
  - Create OBZ archives with generated manifests and validated media resources.
14
14
  - Use exported [Zod](https://zod.dev/) schemas and inferred TypeScript types.
15
15
  - Preserve unknown fields, including vendor extensions.
16
16
 
17
- It focuses on board data and archives only. It does not render boards, play media, fetch remote resources, or resolve navigation and media references.
18
-
19
17
  ## Install
20
18
 
21
19
  ```bash
22
- npm install @shayc/open-board-format zod
20
+ npm install @shayc/open-board-format
23
21
  ```
24
22
 
25
- `zod ^4.4.3` is a required peer dependency.
23
+ Requires `zod ^4.0.0` as a peer dependency.
26
24
 
27
- Works in browsers and Node.js. Browser `File` uploads and Node.js `Buffer` values use the same loading API. Pure ESM; CommonJS is not supported.
25
+ Works in browsers and Node.js. Browser `File` uploads and Node.js `Buffer` values use the same loading API.
28
26
 
29
27
  ## Quick start
30
28
 
31
29
  ```ts
32
30
  import { loadBoard } from "@shayc/open-board-format";
33
31
 
34
- const loaded = await loadBoard(input);
32
+ const result = await loadBoard(input);
35
33
  ```
36
34
 
37
35
  `loadBoard` accepts a `File`, `Blob`, `ArrayBuffer`, or `ArrayBufferView` and detects the format from the bytes, not the filename. It returns a TypeScript discriminated union: OBF files contain a board directly, while OBZ files contain an archive whose `rootBoard` is the entry point.
38
36
 
37
+ `File` refers to the Web API object, not a filesystem path.
38
+
39
39
  ```ts
40
- const board = loaded.format === "obf" ? loaded.board : loaded.archive.rootBoard;
40
+ const board = result.format === "obf" ? result.board : result.archive.rootBoard;
41
41
  ```
42
42
 
43
43
  ## Formats
44
44
 
45
- - **OBF (`.obf`)** is one JSON communication board.
45
+ - **OBF (`.obf`)** is a single JSON communication board.
46
46
  - **OBZ (`.obz`)** is a ZIP archive containing one or more boards and optional media.
47
47
 
48
48
  ```text
@@ -58,15 +58,6 @@ my-board.obz
58
58
 
59
59
  Every OBZ archive requires `manifest.json` at its root, even when it contains only one board.
60
60
 
61
- ### Which function should I call?
62
-
63
- - Unknown file: `loadBoard(input)`
64
- - Known `.obf` file: `loadOBF(file)`
65
- - Known `.obz` input: `extractOBZ(input)`
66
- - Creating an archive: `createOBZ(...)`
67
-
68
- See the [API reference](#api-reference) for the complete function list. Here, `File` means the Web Platform object, not a filesystem path.
69
-
70
61
  ## Examples
71
62
 
72
63
  ### Read an OBZ archive
@@ -84,21 +75,21 @@ The returned `ParsedOBZ` contains:
84
75
  - `boards`: a `Map` keyed by board ID.
85
76
  - `resources`: a `Map` containing the raw bytes of every file entry.
86
77
 
87
- `resources` includes the manifest, board files, media, and unrelated extra files. Directory-marker entries are omitted.
78
+ `resources` includes the manifest, board files, media, and any other files in the archive.
88
79
 
89
80
  For untrusted archives, configure [extraction limits](#extraction-limits).
90
81
 
91
82
  ### Create an OBZ archive
92
83
 
93
- Given an existing board and its media resources:
84
+ Given a board and its media resources:
94
85
 
95
86
  ```ts
96
87
  import { createOBZ } from "@shayc/open-board-format";
97
88
 
98
- const blob = await createOBZ([existingBoard], existingBoard.id, resources);
89
+ const obz = await createOBZ([board], board.id, resources);
99
90
  ```
100
91
 
101
- `createOBZ` generates the manifest automatically, writes boards to `boards/<encoded-id>.obf`, and uses `rootBoardId` as the archive's entry board.
92
+ `createOBZ` generates the manifest automatically, writes boards to `boards/<encoded-id>.obf`, and uses `rootBoardId` as the archive's entry-point board.
102
93
 
103
94
  ### Validate a board
104
95
 
@@ -192,7 +183,7 @@ Main exports include:
192
183
 
193
184
  ### Errors
194
185
 
195
- Expected parsing, validation, and archive-domain failures from the high-level APIs use `OBFError`.
186
+ High-level APIs report expected parsing, validation, and archive failures as `OBFError`.
196
187
 
197
188
  Branch on `error.info.code`, not `error.message`.
198
189
 
@@ -293,7 +284,7 @@ Found a vulnerability? Email [shayc@outlook.com](mailto:shayc@outlook.com) rathe
293
284
 
294
285
  - Pure ESM for Node.js `>=22` and modern browsers; CommonJS is unsupported.
295
286
  - Browser environments must provide `Blob`, `File`, `TextEncoder`, and `TextDecoder`.
296
- - `fflate` is the only runtime dependency; `zod ^4.4.3` is a peer dependency.
287
+ - `fflate` is the only runtime dependency; `zod ^4.0.0` is a peer dependency.
297
288
  - CI covers Node.js 22, 24, and 26. Browser engines are not currently tested in CI.
298
289
 
299
290
  ## Project
package/dist/index.d.mts CHANGED
@@ -13,7 +13,7 @@ type OBFFormatVersion = z.infer<typeof OBFFormatVersionSchema>;
13
13
  * `fr-CA`). Not strictly validated — any string is accepted.
14
14
  */
15
15
  declare const OBFLocaleCodeSchema: z.ZodString;
16
- /** Locale identifier, typically a BCP 47 language tag, e.g., `en`, `en-US`. See {@link OBFLocaleCodeSchema}. */
16
+ /** Locale identifier, typically a BCP 47 language tag; accepts any string. See {@link OBFLocaleCodeSchema}. */
17
17
  type OBFLocaleCode = z.infer<typeof OBFLocaleCodeSchema>;
18
18
  /**
19
19
  * Translations for a single locale, keyed by the source string,
@@ -61,7 +61,8 @@ type OBFLicense = z.infer<typeof OBFLicenseSchema>;
61
61
  /**
62
62
  * Common properties for media resources (images and sounds).
63
63
  *
64
- * When multiple references are provided, they should be used in the following order:
64
+ * Resolve multiple references in this order:
65
+ *
65
66
  * 1. `data`
66
67
  * 2. `path`
67
68
  * 3. `url`
@@ -99,7 +100,8 @@ type OBFSymbolInfo = z.infer<typeof OBFSymbolInfoSchema>;
99
100
  * Image resource, extending {@link OBFMediaSchema} with optional
100
101
  * symbol and dimension properties.
101
102
  *
102
- * When resolving the image, consumers should prefer sources in this order:
103
+ * Resolve multiple image sources in this order:
104
+ *
103
105
  * 1. `data`
104
106
  * 2. `path`
105
107
  * 3. `url`
@@ -161,7 +163,7 @@ declare const OBFLoadBoardSchema: z.ZodObject<{
161
163
  /** Reference to another board, resolved by ID, path, or URL. See {@link OBFLoadBoardSchema}. */
162
164
  type OBFLoadBoard = z.infer<typeof OBFLoadBoardSchema>;
163
165
  /**
164
- * Interactive element on a board, optionally linked to images, sounds, and actions.
166
+ * Interactive board element, optionally linked to images, sounds, and actions.
165
167
  */
166
168
  declare const OBFButtonSchema: z.ZodObject<{
167
169
  id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
@@ -185,8 +187,9 @@ declare const OBFButtonSchema: z.ZodObject<{
185
187
  width: z.ZodOptional<z.ZodNumber>;
186
188
  height: z.ZodOptional<z.ZodNumber>;
187
189
  }, z.core.$loose>;
188
- /** Interactive element on a board, optionally linked to images, sounds, and actions. See {@link OBFButtonSchema}. */
190
+ /** Interactive board element, optionally linked to images, sounds, and actions. See {@link OBFButtonSchema}. */
189
191
  type OBFButton = z.infer<typeof OBFButtonSchema>;
192
+ /** Row-and-column layout that arranges buttons by their IDs. */
190
193
  declare const OBFGridSchema: z.ZodObject<{
191
194
  rows: z.ZodNumber;
192
195
  columns: z.ZodNumber;
@@ -310,7 +313,7 @@ type OBFIssue = z.core.$ZodIssue;
310
313
  * Switch on `code`; each variant carries the fields relevant to it. When a
311
314
  * failure wraps an underlying error it lives on the standard `error.cause`,
312
315
  * never duplicated here. The only optional field is `invalid-board`'s
313
- * `boardId`, absent when validation runs on a value with no known id.
316
+ * `boardId`, absent when validation runs before a board `id` is known.
314
317
  */
315
318
  type OBFErrorInfo =
316
319
  /** Input was not parseable JSON. */
@@ -369,7 +372,7 @@ type OBFErrorInfo =
369
372
  boardId: string;
370
373
  path: string;
371
374
  } |
372
- /** A board's `id` disagrees with the id the manifest declares for it. */
375
+ /** A board's `id` disagrees with its manifest key. */
373
376
  {
374
377
  code: "board-id-mismatch";
375
378
  path: string;
@@ -393,7 +396,7 @@ type OBFErrorInfo =
393
396
  mediaId: string;
394
397
  path: string;
395
398
  } |
396
- /** Two boards declare the same media id with different paths. */
399
+ /** Two boards declare the same media ID with different paths. */
397
400
  {
398
401
  code: "conflicting-paths";
399
402
  kind: "image" | "sound";
@@ -417,7 +420,7 @@ type OBFErrorInfo =
417
420
  /** Every `code` an {@link OBFError} can carry. */
418
421
  type OBFErrorCode = OBFErrorInfo["code"];
419
422
  /**
420
- * The single error type thrown by `@shayc/open-board-format`.
423
+ * Structured error for expected failures from `@shayc/open-board-format`.
421
424
  *
422
425
  * Branch on {@link OBFError.info} (a discriminated {@link OBFErrorInfo}) rather
423
426
  * than parsing {@link OBFError.message}. Any underlying error — a `JSON.parse`
@@ -448,8 +451,7 @@ declare function parseOBF(json: string): OBFBoard;
448
451
  /**
449
452
  * Read a `File` and parse its contents as a validated OBF board.
450
453
  *
451
- * This relies on the browser `File` API; for Node environments,
452
- * read the file to a string and pass it to {@link parseOBF} instead.
454
+ * For a `Blob`, `ArrayBuffer`, or typed-array input, use `loadBoard` instead.
453
455
  *
454
456
  * @param file - A `File` handle pointing to an `.obf` file.
455
457
  * @returns The validated board object.
@@ -487,10 +489,9 @@ declare function stringifyOBF(board: OBFBoard): string;
487
489
  */
488
490
  type BinaryInput = File | Blob | ArrayBuffer | ArrayBufferView;
489
491
  /**
490
- * Optional caps on declared uncompressed sizes, checked per entry against the
491
- * archive's ZIP metadata before that entry is inflated. Entries accepted
492
- * before a later entry trips a limit have already been inflated, but total
493
- * allocation stays bounded by the caps.
492
+ * Optional caps on declared uncompressed sizes, checked against ZIP metadata
493
+ * before each entry is inflated. These limits reduce allocation risk but are
494
+ * not strict memory guarantees because archive metadata can be dishonest.
494
495
  */
495
496
  interface UnzipLimits {
496
497
  /** Max declared uncompressed size of any single entry, in bytes. */
@@ -592,7 +593,7 @@ declare function loadOBZ(file: File, options?: UnzipOptions): Promise<ParsedOBZ>
592
593
  * declared uncompressed sizes, checked before inflation. No limits are
593
594
  * applied by default.
594
595
  * @returns A {@link ParsedOBZ} with the archive's manifest, boards, root
595
- * board, and resources.
596
+ * board, and resources.
596
597
  *
597
598
  * @throws {@link OBFError}; branch on `info.code`: `"not-zip"`,
598
599
  * `"unreadable-zip"`, `"archive-too-large"` (a limit in `options.limits` is
@@ -622,16 +623,23 @@ declare function parseManifest(json: string): OBFManifest;
622
623
  * Every failure is an {@link OBFError}; branch on `info.code`.
623
624
  *
624
625
  * @param boards - The boards to include in the archive.
625
- * @param rootBoardId - The ID of the board that serves as the archive's entry point.
626
- * @param resources - Optional map of file paths to binary content (images, sounds, etc.).
626
+ * @param rootBoardId - The entry-point board's ID.
627
+ * @param resources - Optional map of archive paths to binary content.
627
628
  * @returns A `Blob` containing the compressed OBZ archive.
628
629
  *
629
- * @throws {@link OBFError} `"unknown-root"` if `rootBoardId` does not match any of the supplied boards.
630
- * @throws {@link OBFError} `"duplicate-board"` if two supplied boards share the same ID.
631
- * @throws {@link OBFError} `"invalid-board"` if a supplied board fails schema validation.
632
- * @throws {@link OBFError} `"conflicting-paths"` if two boards declare the same media ID with conflicting paths.
633
- * @throws {@link OBFError} `"missing-resource"` if a board declares an image or sound `path` with no matching entry in `resources`.
634
- * @throws {@link OBFError} `"path-collision"` if a `resources` entry would overwrite the generated `manifest.json` or a board file.
630
+ * @throws {@link OBFError} `"unknown-root"` if `rootBoardId` does not match any
631
+ * supplied board.
632
+ * @throws {@link OBFError} `"duplicate-board"` if two supplied boards share
633
+ * the same ID.
634
+ * @throws {@link OBFError} `"invalid-board"` if a supplied board fails schema
635
+ * validation.
636
+ * @throws {@link OBFError} `"conflicting-paths"` if two boards map the same
637
+ * media ID to different paths.
638
+ * @throws {@link OBFError} `"missing-resource"` if a board declares an image
639
+ * or sound `path` with no matching entry in `resources`.
640
+ * @throws {@link OBFError} `"path-collision"` if a resource would overwrite
641
+ * the generated manifest or a board file.
642
+ * @throws {@link OBFError} `"zip-failed"` if archive compression fails.
635
643
  */
636
644
  declare function createOBZ(boards: OBFBoard[], rootBoardId: string, resources?: Map<string, Uint8Array | ArrayBuffer>): Promise<Blob>;
637
645
  //#endregion
@@ -645,7 +653,7 @@ declare function createOBZ(boards: OBFBoard[], rootBoardId: string, resources?:
645
653
  * ```ts
646
654
  * const loaded = await loadBoard(file);
647
655
  * if (loaded.format === "obz") {
648
- * loaded.archive.rootBoard; // home board of the ParsedOBZ archive
656
+ * loaded.archive.rootBoard; // entry-point board of the ParsedOBZ archive
649
657
  * } else {
650
658
  * loaded.board; // OBFBoard
651
659
  * }
package/dist/index.mjs CHANGED
@@ -64,7 +64,8 @@ const OBFLicenseSchema = z.looseObject({
64
64
  /**
65
65
  * Common properties for media resources (images and sounds).
66
66
  *
67
- * When multiple references are provided, they should be used in the following order:
67
+ * Resolve multiple references in this order:
68
+ *
68
69
  * 1. `data`
69
70
  * 2. `path`
70
71
  * 3. `url`
@@ -103,7 +104,8 @@ const OBFSymbolInfoSchema = z.looseObject({
103
104
  * Image resource, extending {@link OBFMediaSchema} with optional
104
105
  * symbol and dimension properties.
105
106
  *
106
- * When resolving the image, consumers should prefer sources in this order:
107
+ * Resolve multiple image sources in this order:
108
+ *
107
109
  * 1. `data`
108
110
  * 2. `path`
109
111
  * 3. `url`
@@ -138,7 +140,7 @@ const OBFLoadBoardSchema = z.looseObject({
138
140
  path: z.string().optional()
139
141
  });
140
142
  /**
141
- * Interactive element on a board, optionally linked to images, sounds, and actions.
143
+ * Interactive board element, optionally linked to images, sounds, and actions.
142
144
  */
143
145
  const OBFButtonSchema = z.looseObject({
144
146
  /** Unique identifier for the button. */
@@ -164,22 +166,21 @@ const OBFButtonSchema = z.looseObject({
164
166
  /** Information to load another board when this button is activated. */
165
167
  load_board: OBFLoadBoardSchema.optional(),
166
168
  /**
167
- * Background color of the button, typically `rgb`/`rgba`. Not
168
- * strictly validated — any string is accepted.
169
+ * Background color, typically an `rgb()` or `rgba()` value. Accepts any
170
+ * string.
169
171
  */
170
172
  background_color: z.string().optional(),
171
173
  /**
172
- * Border color of the button, typically `rgb`/`rgba`. Not strictly
173
- * validated — any string is accepted.
174
+ * Border color, typically an `rgb()` or `rgba()` value. Accepts any string.
174
175
  */
175
176
  border_color: z.string().optional(),
176
- /** Vertical position for absolute positioning (0.0 to 1.0). */
177
+ /** Vertical position for absolute positioning, from 0 to 1. */
177
178
  top: z.number().min(0).max(1).optional(),
178
- /** Horizontal position for absolute positioning (0.0 to 1.0). */
179
+ /** Horizontal position for absolute positioning, from 0 to 1. */
179
180
  left: z.number().min(0).max(1).optional(),
180
- /** Width of the button for absolute positioning (0.0 to 1.0). */
181
+ /** Width of the button for absolute positioning, from 0 to 1. */
181
182
  width: z.number().min(0).max(1).optional(),
182
- /** Height of the button for absolute positioning (0.0 to 1.0). */
183
+ /** Height of the button for absolute positioning, from 0 to 1. */
183
184
  height: z.number().min(0).max(1).optional()
184
185
  }).refine((b) => {
185
186
  const set = [
@@ -190,15 +191,13 @@ const OBFButtonSchema = z.looseObject({
190
191
  ].filter((v) => v !== void 0);
191
192
  return set.length === 0 || set.length === 4;
192
193
  }, { message: "Absolute positioning requires all of top, left, width, and height (or none)" });
194
+ /** Row-and-column layout that arranges buttons by their IDs. */
193
195
  const OBFGridSchema = z.looseObject({
194
196
  /** Number of rows in the grid. */
195
197
  rows: z.number().int().min(1).max(100),
196
198
  /** Number of columns in the grid. */
197
199
  columns: z.number().int().min(1).max(100),
198
- /**
199
- * 2D array representing the order of buttons by their IDs.
200
- * Each sub-array corresponds to a row, and each element is a button ID or null for empty slots.
201
- */
200
+ /** Button IDs by row; `null` marks an empty cell. */
202
201
  order: z.array(z.array(z.union([OBFIDSchema, z.null()])))
203
202
  }).refine((g) => g.order.length === g.rows, { message: "Grid order length must match rows" }).refine((g) => g.order.every((row) => row.length === g.columns), { message: "Each grid row must have length equal to columns" });
204
203
  /**
@@ -209,7 +208,7 @@ const OBFBoardSchema = z.looseObject({
209
208
  format: OBFFormatVersionSchema,
210
209
  /** Unique identifier for the board. */
211
210
  id: OBFIDSchema,
212
- /** Locale of the board as a BCP 47 language tag, e.g., `en`, `en-US`. */
211
+ /** Locale identifier, typically a BCP 47 language tag such as `en` or `en-US`. */
213
212
  locale: OBFLocaleCodeSchema.optional(),
214
213
  /** List of buttons on the board. */
215
214
  buttons: z.array(OBFButtonSchema),
@@ -256,11 +255,10 @@ const OBFManifestSchema = z.looseObject({
256
255
  /**
257
256
  * Typed errors for `@shayc/open-board-format`.
258
257
  *
259
- * Every failure thrown by this package is an {@link OBFError} carrying a
260
- * discriminated {@link OBFErrorInfo} on its `info` property. Switch on
261
- * `error.info.code` to get exactly the structured context for that failure —
262
- * the human-readable `message` is derived from `info` and is not part of the
263
- * stable contract.
258
+ * Expected parsing, validation, and archive failures use {@link OBFError}. Each
259
+ * carries a discriminated {@link OBFErrorInfo} on its `info` property. Switch
260
+ * on `error.info.code` for the structured context; the human-readable
261
+ * `message` is derived from `info` and is not part of the stable contract.
264
262
  *
265
263
  * ```ts
266
264
  * try {
@@ -279,7 +277,7 @@ const OBFManifestSchema = z.looseObject({
279
277
  * ```
280
278
  */
281
279
  /**
282
- * The single error type thrown by `@shayc/open-board-format`.
280
+ * Structured error for expected failures from `@shayc/open-board-format`.
283
281
  *
284
282
  * Branch on {@link OBFError.info} (a discriminated {@link OBFErrorInfo}) rather
285
283
  * than parsing {@link OBFError.message}. Any underlying error — a `JSON.parse`
@@ -359,8 +357,7 @@ function parseOBF(json) {
359
357
  /**
360
358
  * Read a `File` and parse its contents as a validated OBF board.
361
359
  *
362
- * This relies on the browser `File` API; for Node environments,
363
- * read the file to a string and pass it to {@link parseOBF} instead.
360
+ * For a `Blob`, `ArrayBuffer`, or typed-array input, use `loadBoard` instead.
364
361
  *
365
362
  * @param file - A `File` handle pointing to an `.obf` file.
366
363
  * @returns The validated board object.
@@ -403,8 +400,8 @@ function stringifyOBF(board) {
403
400
  * First two bytes of every ZIP archive — the ASCII letters `PK`,
404
401
  * after Phil Katz, creator of the format.
405
402
  *
406
- * Only the 2-byte prefix is checked intentionally: this keeps the
407
- * test lightweight and sufficient for distinguishing ZIP from JSON.
403
+ * Only the 2-byte prefix is checked intentionally: this keeps detection
404
+ * lightweight and is sufficient for distinguishing ZIP from JSON.
408
405
  */
409
406
  const ZIP_MAGIC = [80, 75];
410
407
  /** Balanced speed-vs-size deflate level, on fflate's 0–9 scale (0 = store). */
@@ -586,7 +583,7 @@ async function loadOBZ(file, options) {
586
583
  * declared uncompressed sizes, checked before inflation. No limits are
587
584
  * applied by default.
588
585
  * @returns A {@link ParsedOBZ} with the archive's manifest, boards, root
589
- * board, and resources.
586
+ * board, and resources.
590
587
  *
591
588
  * @throws {@link OBFError}; branch on `info.code`: `"not-zip"`,
592
589
  * `"unreadable-zip"`, `"archive-too-large"` (a limit in `options.limits` is
@@ -644,16 +641,23 @@ function parseManifest(json) {
644
641
  * Every failure is an {@link OBFError}; branch on `info.code`.
645
642
  *
646
643
  * @param boards - The boards to include in the archive.
647
- * @param rootBoardId - The ID of the board that serves as the archive's entry point.
648
- * @param resources - Optional map of file paths to binary content (images, sounds, etc.).
644
+ * @param rootBoardId - The entry-point board's ID.
645
+ * @param resources - Optional map of archive paths to binary content.
649
646
  * @returns A `Blob` containing the compressed OBZ archive.
650
647
  *
651
- * @throws {@link OBFError} `"unknown-root"` if `rootBoardId` does not match any of the supplied boards.
652
- * @throws {@link OBFError} `"duplicate-board"` if two supplied boards share the same ID.
653
- * @throws {@link OBFError} `"invalid-board"` if a supplied board fails schema validation.
654
- * @throws {@link OBFError} `"conflicting-paths"` if two boards declare the same media ID with conflicting paths.
655
- * @throws {@link OBFError} `"missing-resource"` if a board declares an image or sound `path` with no matching entry in `resources`.
656
- * @throws {@link OBFError} `"path-collision"` if a `resources` entry would overwrite the generated `manifest.json` or a board file.
648
+ * @throws {@link OBFError} `"unknown-root"` if `rootBoardId` does not match any
649
+ * supplied board.
650
+ * @throws {@link OBFError} `"duplicate-board"` if two supplied boards share
651
+ * the same ID.
652
+ * @throws {@link OBFError} `"invalid-board"` if a supplied board fails schema
653
+ * validation.
654
+ * @throws {@link OBFError} `"conflicting-paths"` if two boards map the same
655
+ * media ID to different paths.
656
+ * @throws {@link OBFError} `"missing-resource"` if a board declares an image
657
+ * or sound `path` with no matching entry in `resources`.
658
+ * @throws {@link OBFError} `"path-collision"` if a resource would overwrite
659
+ * the generated manifest or a board file.
660
+ * @throws {@link OBFError} `"zip-failed"` if archive compression fails.
657
661
  */
658
662
  async function createOBZ(boards, rootBoardId, resources) {
659
663
  if (!boards.some((board) => board.id === rootBoardId)) throw new OBFError({
@@ -712,23 +716,22 @@ async function createOBZ(boards, rootBoardId, resources) {
712
716
  return new Blob([compressed], { type: "application/zip" });
713
717
  }
714
718
  /**
715
- * Derive a board's archive path from its id.
719
+ * Derive a board's archive path from its ID.
716
720
  *
717
- * Board ids are spec-legal as any non-empty string, but archive paths give
718
- * `/` and `\` structural meaning. Percent-encoding the id keeps the mapping
719
- * deterministic and collision-free without rejecting any id the schema
720
- * already allows a `/` or `..` in the id just becomes part of a filename,
721
- * never a path segment.
721
+ * Board IDs may be any non-empty string, but archive paths give `/` and `\`
722
+ * structural meaning. Percent-encoding keeps the mapping deterministic and
723
+ * collision-free without rejecting any valid ID: `/` or `..` is encoded as
724
+ * filename text, never interpreted as a path segment.
722
725
  */
723
726
  function boardPath(id) {
724
727
  return `boards/${encodeURIComponent(id)}.obf`;
725
728
  }
726
729
  /**
727
- * Walk every board's media collection and produce the `{ id -> path }` map
728
- * the spec calls "redundant but still required" for the OBZ manifest.
730
+ * Walk every board's media collection and produce the ID-to-path map the spec
731
+ * calls "redundant but still required" for the OBZ manifest.
729
732
  *
730
- * Throws when two boards declare the same media ID with conflicting paths
731
- * a silent OBZ that points at a non-existent file is worse than a clear error.
733
+ * Throws when two boards map the same media ID to different paths. Without
734
+ * this check, the generated manifest could silently point to the wrong file.
732
735
  */
733
736
  function collectMediaPaths(boards, kind) {
734
737
  const paths = {};
@@ -749,8 +752,8 @@ function collectMediaPaths(boards, kind) {
749
752
  * Assert that every media path the generated manifest declares exists as an
750
753
  * archive entry — the same contract {@link extractOBZ} assumes when reading.
751
754
  *
752
- * Only media that declared a `path` reach this check, so `url`/`data`-only
753
- * media are never flagged.
755
+ * Only media with a `path` reach this check, so `url`/`data`-only media are
756
+ * never flagged.
754
757
  */
755
758
  function assertPathsPresent(kind, paths, entries) {
756
759
  for (const [id, path] of Object.entries(paths)) if (!entries.has(path)) throw new OBFError({
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["_exhaustive","fflateUnzip"],"sources":["../src/schema.ts","../src/errors.ts","../src/obf.ts","../src/zip.ts","../src/obz.ts","../src/load-board.ts"],"sourcesContent":["/**\n * Zod schemas for the Open Board Format (OBF) data model.\n *\n * Official OBF specification: https://www.openboardformat.org/docs\n */\n\nimport { z } from \"zod\";\n\n/** Optional URL that treats empty strings as undefined. */\nconst OBFOptionalUrlSchema = z\n .union([z.url(), z.literal(\"\")])\n .transform((val) => (val === \"\" ? undefined : val))\n .optional();\n\n/** Optional email that treats empty strings as undefined. */\nconst OBFOptionalEmailSchema = z\n .union([z.email(), z.literal(\"\")])\n .transform((val) => (val === \"\" ? undefined : val))\n .optional();\n\n/** Optional ID that treats empty strings as undefined. */\nconst OBFOptionalIDSchema = z\n .union([z.string(), z.number()])\n .transform((val) => {\n const str = String(val);\n return str === \"\" ? undefined : str;\n })\n .optional();\n\n/** Unique board-element identifier, coerced to a non-empty string. */\nexport const OBFIDSchema = z\n .union([z.string(), z.number()])\n .transform((val) => String(val))\n .pipe(z.string().min(1));\n\n/** Unique board-element identifier, coerced to a non-empty string. See {@link OBFIDSchema}. */\nexport type OBFID = z.infer<typeof OBFIDSchema>;\n\n/** Format version of the Open Board Format, e.g., `open-board-0.1`. */\nexport const OBFFormatVersionSchema = z.string().regex(/^open-board-.+$/);\n\n/** Format version of the Open Board Format, e.g., `open-board-0.1`. See {@link OBFFormatVersionSchema}. */\nexport type OBFFormatVersion = z.infer<typeof OBFFormatVersionSchema>;\n\n/**\n * Locale identifier, typically a BCP 47 language tag (e.g., `en`, `en-US`,\n * `fr-CA`). Not strictly validated — any string is accepted.\n */\nexport const OBFLocaleCodeSchema = z.string();\n\n/** Locale identifier, typically a BCP 47 language tag, e.g., `en`, `en-US`. See {@link OBFLocaleCodeSchema}. */\nexport type OBFLocaleCode = z.infer<typeof OBFLocaleCodeSchema>;\n\n/**\n * Translations for a single locale, keyed by the source string,\n * e.g., `{ \"hello\": \"hola\" }`.\n */\nexport const OBFLocalizedStringsSchema = z.record(z.string(), z.string());\n\n/** Translations for a single locale, keyed by the source string. See {@link OBFLocalizedStringsSchema}. */\nexport type OBFLocalizedStrings = z.infer<typeof OBFLocalizedStringsSchema>;\n\n/**\n * Locale-keyed dictionary of translated strings,\n * e.g., `{ en: { greeting: \"Hello\" }, fr: { greeting: \"Bonjour\" } }`.\n */\nexport const OBFStringsSchema = z.record(z.string(), OBFLocalizedStringsSchema);\n\n/** Locale-keyed dictionary of translated strings. See {@link OBFStringsSchema}. */\nexport type OBFStrings = z.infer<typeof OBFStringsSchema>;\n\n/**\n * Spelling action: a `+` prefix followed by the text to append,\n * e.g., `+hello`.\n */\nexport const OBFSpellingActionSchema = z.string().regex(/^\\+.+$/);\n\n/** Spelling action: a `+` prefix followed by the text to append, e.g., `+hello`. See {@link OBFSpellingActionSchema}. */\nexport type OBFSpellingAction = z.infer<typeof OBFSpellingActionSchema>;\n\n/**\n * Specialty action prefixed with `:`, e.g., `:clear`.\n * Custom extensions use the `:ext_` prefix.\n */\nexport const OBFSpecialtyActionSchema = z\n .string()\n .regex(/^:[a-z][a-z0-9_-]*$/i);\n\n/** Specialty action prefixed with `:`, e.g., `:clear`. See {@link OBFSpecialtyActionSchema}. */\nexport type OBFSpecialtyAction = z.infer<typeof OBFSpecialtyActionSchema>;\n\n/** Union of spelling and specialty actions that a button can trigger. */\nexport const OBFButtonActionSchema = z.union([\n OBFSpellingActionSchema,\n OBFSpecialtyActionSchema,\n]);\n\n/** Union of spelling and specialty actions that a button can trigger. See {@link OBFButtonActionSchema}. */\nexport type OBFButtonAction = z.infer<typeof OBFButtonActionSchema>;\n\n/** License terms and attribution for a resource. */\nexport const OBFLicenseSchema = z.looseObject({\n /** Type of the license, e.g., `CC-BY-SA`. */\n type: z.string(),\n /** URL to the license terms. */\n copyright_notice_url: OBFOptionalUrlSchema,\n /** Source URL of the resource. */\n source_url: OBFOptionalUrlSchema,\n /** Name of the author. */\n author_name: z.string().optional(),\n /** URL of the author's webpage. */\n author_url: OBFOptionalUrlSchema,\n /** Email address of the author. */\n author_email: OBFOptionalEmailSchema,\n});\n\n/** License terms and attribution for a resource. See {@link OBFLicenseSchema}. */\nexport type OBFLicense = z.infer<typeof OBFLicenseSchema>;\n\n/**\n * Common properties for media resources (images and sounds).\n *\n * When multiple references are provided, they should be used in the following order:\n * 1. `data`\n * 2. `path`\n * 3. `url`\n *\n * `data_url` is not part of this fallback chain — it is an API endpoint for\n * retrieving information about the resource, not an alternative source of\n * the media bytes.\n */\nexport const OBFMediaSchema = z.looseObject({\n /** Unique identifier for the media resource. */\n id: OBFIDSchema,\n /** Media data inlined as a `data:` URI. */\n data: z.string().optional(),\n /** Path to the media file within an `.obz` package. */\n path: z.string().optional(),\n /**\n * URL of an API endpoint for fetching the media programmatically —\n * not a `data:` URI (that is `data`).\n */\n data_url: OBFOptionalUrlSchema,\n /** URL to the media resource. */\n url: OBFOptionalUrlSchema,\n /** MIME type of the media, e.g., `image/png`, `audio/mpeg`. */\n content_type: z.string().optional(),\n /** Licensing information for the media. */\n license: OBFLicenseSchema.optional(),\n});\n\n/** Common properties for media resources (images and sounds). See {@link OBFMediaSchema}. */\nexport type OBFMedia = z.infer<typeof OBFMediaSchema>;\n\n/** Reference to a symbol in a proprietary symbol set (e.g., SymbolStix). */\nexport const OBFSymbolInfoSchema = z.looseObject({\n /** Name of the symbol set, e.g., `symbolstix`. */\n set: z.string(),\n /** Filename of the symbol within the set. */\n filename: z.string(),\n});\n\n/** Reference to a symbol in a proprietary symbol set. See {@link OBFSymbolInfoSchema}. */\nexport type OBFSymbolInfo = z.infer<typeof OBFSymbolInfoSchema>;\n\n/**\n * Image resource, extending {@link OBFMediaSchema} with optional\n * symbol and dimension properties.\n *\n * When resolving the image, consumers should prefer sources in this order:\n * 1. `data`\n * 2. `path`\n * 3. `url`\n * 4. `symbol`\n */\nexport const OBFImageSchema = OBFMediaSchema.extend({\n /** Information about a symbol from a proprietary symbol set. */\n symbol: OBFSymbolInfoSchema.optional(),\n /** Width of the image in pixels. */\n width: z.number().optional(),\n /** Height of the image in pixels. */\n height: z.number().optional(),\n});\n\n/** Image resource with optional symbol and dimension properties. See {@link OBFImageSchema}. */\nexport type OBFImage = z.infer<typeof OBFImageSchema>;\n\n/**\n * Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.\n */\nexport const OBFSoundSchema = OBFMediaSchema;\n\n/** Audio resource, identical to {@link OBFMediaSchema}. See {@link OBFSoundSchema}. */\nexport type OBFSound = z.infer<typeof OBFSoundSchema>;\n\n/** Reference to another board, resolved by ID, path, or URL. */\nexport const OBFLoadBoardSchema = z.looseObject({\n /** Unique identifier of the board to load. */\n id: OBFOptionalIDSchema,\n /** Name of the board to load. */\n name: z.string().optional(),\n /**\n * URL of an API endpoint for fetching the board programmatically —\n * not a `data:` URI.\n */\n data_url: OBFOptionalUrlSchema,\n /** URL to access the board via a web browser. */\n url: OBFOptionalUrlSchema,\n /** Path to the board within an `.obz` package. */\n path: z.string().optional(),\n});\n\n/** Reference to another board, resolved by ID, path, or URL. See {@link OBFLoadBoardSchema}. */\nexport type OBFLoadBoard = z.infer<typeof OBFLoadBoardSchema>;\n\n/**\n * Interactive element on a board, optionally linked to images, sounds, and actions.\n */\nexport const OBFButtonSchema = z\n .looseObject({\n /** Unique identifier for the button. */\n id: OBFIDSchema,\n /** Label text displayed on the button. */\n label: z.string().optional(),\n /** Alternative text for vocalization when the button is activated. */\n vocalization: z.string().optional(),\n /** Identifier of the image associated with the button. */\n image_id: OBFOptionalIDSchema,\n /** Identifier of the sound associated with the button. */\n sound_id: OBFOptionalIDSchema,\n /**\n * Action triggered by the button. When `actions` is also set, this is\n * the single-action fallback for apps that support one action per button.\n */\n action: OBFButtonActionSchema.optional(),\n /**\n * Multiple actions executed in order. Apps that support it should\n * prefer this over the single `action` fallback.\n */\n actions: z.array(OBFButtonActionSchema).optional(),\n /** Information to load another board when this button is activated. */\n load_board: OBFLoadBoardSchema.optional(),\n /**\n * Background color of the button, typically `rgb`/`rgba`. Not\n * strictly validated — any string is accepted.\n */\n background_color: z.string().optional(),\n /**\n * Border color of the button, typically `rgb`/`rgba`. Not strictly\n * validated — any string is accepted.\n */\n border_color: z.string().optional(),\n /** Vertical position for absolute positioning (0.0 to 1.0). */\n top: z.number().min(0).max(1).optional(),\n /** Horizontal position for absolute positioning (0.0 to 1.0). */\n left: z.number().min(0).max(1).optional(),\n /** Width of the button for absolute positioning (0.0 to 1.0). */\n width: z.number().min(0).max(1).optional(),\n /** Height of the button for absolute positioning (0.0 to 1.0). */\n height: z.number().min(0).max(1).optional(),\n })\n .refine(\n (b) => {\n const set = [b.top, b.left, b.width, b.height].filter(\n (v) => v !== undefined,\n );\n return set.length === 0 || set.length === 4;\n },\n {\n message:\n \"Absolute positioning requires all of top, left, width, and height (or none)\",\n },\n );\n\n/** Interactive element on a board, optionally linked to images, sounds, and actions. See {@link OBFButtonSchema}. */\nexport type OBFButton = z.infer<typeof OBFButtonSchema>;\n\n/**\n * Row-and-column layout that arranges buttons by their IDs.\n */\n/**\n * Upper bound on grid dimensions. A board only needs these to lay out cells;\n * a consumer allocates rows × columns, so an unbounded value (e.g. 1e9) would\n * exhaust memory. 100 is generous headroom over any real AAC board (~15–20)\n * and caps the hostile worst case at 100 × 100 cells.\n */\nconst MAX_GRID_ROWS = 100;\nconst MAX_GRID_COLUMNS = 100;\n\nexport const OBFGridSchema = z\n .looseObject({\n /** Number of rows in the grid. */\n rows: z.number().int().min(1).max(MAX_GRID_ROWS),\n /** Number of columns in the grid. */\n columns: z.number().int().min(1).max(MAX_GRID_COLUMNS),\n /**\n * 2D array representing the order of buttons by their IDs.\n * Each sub-array corresponds to a row, and each element is a button ID or null for empty slots.\n */\n order: z.array(z.array(z.union([OBFIDSchema, z.null()]))),\n })\n .refine((g) => g.order.length === g.rows, {\n message: \"Grid order length must match rows\",\n })\n .refine((g) => g.order.every((row) => row.length === g.columns), {\n message: \"Each grid row must have length equal to columns\",\n });\n\n/** Row-and-column layout that arranges buttons by their IDs. See {@link OBFGridSchema}. */\nexport type OBFGrid = z.infer<typeof OBFGridSchema>;\n\n/**\n * Root object of an `.obf` file: the complete definition of a single communication board.\n */\nexport const OBFBoardSchema = z.looseObject({\n /** Format version of the Open Board Format, e.g., `open-board-0.1`. */\n format: OBFFormatVersionSchema,\n /** Unique identifier for the board. */\n id: OBFIDSchema,\n /** Locale of the board as a BCP 47 language tag, e.g., `en`, `en-US`. */\n locale: OBFLocaleCodeSchema.optional(),\n /** List of buttons on the board. */\n buttons: z.array(OBFButtonSchema),\n /** URL where the board can be accessed or downloaded. */\n url: OBFOptionalUrlSchema,\n /** Name of the board. */\n name: z.string().optional(),\n /** Description of the board in HTML format. */\n description_html: z.string().optional(),\n /** Grid layout information for arranging buttons. */\n grid: OBFGridSchema,\n /** List of images used in the board. */\n images: z.array(OBFImageSchema).optional(),\n /** List of sounds used in the board. */\n sounds: z.array(OBFSoundSchema).optional(),\n /** Licensing information for the board. */\n license: OBFLicenseSchema.optional(),\n /** String translations for multiple locales. */\n strings: OBFStringsSchema.optional(),\n});\n\n/** Root object of an `.obf` file: the complete definition of a single communication board. See {@link OBFBoardSchema}. */\nexport type OBFBoard = z.infer<typeof OBFBoardSchema>;\n\n/**\n * Table of contents for an `.obz` package, mapping resource IDs to their archive paths.\n */\nexport const OBFManifestSchema = z\n .looseObject({\n /** Format version of the Open Board Format, e.g., `open-board-0.1`. */\n format: OBFFormatVersionSchema,\n /** Path to the root board within the `.obz` package. */\n root: z.string(),\n /** Mapping of IDs to paths for boards, images, and sounds. */\n paths: z.looseObject({\n /** Mapping of board IDs to their file paths. */\n boards: z.record(z.string(), z.string()),\n /** Mapping of image IDs to their file paths. */\n images: z.record(z.string(), z.string()).optional(),\n /** Mapping of sound IDs to their file paths. */\n sounds: z.record(z.string(), z.string()).optional(),\n }),\n })\n .refine((m) => Object.values(m.paths.boards).includes(m.root), {\n message: \"root must be listed in paths.boards\",\n path: [\"root\"],\n });\n\n/** Table of contents for an `.obz` package, mapping resource IDs to their archive paths. See {@link OBFManifestSchema}. */\nexport type OBFManifest = z.infer<typeof OBFManifestSchema>;\n","/**\n * Typed errors for `@shayc/open-board-format`.\n *\n * Every failure thrown by this package is an {@link OBFError} carrying a\n * discriminated {@link OBFErrorInfo} on its `info` property. Switch on\n * `error.info.code` to get exactly the structured context for that failure —\n * the human-readable `message` is derived from `info` and is not part of the\n * stable contract.\n *\n * ```ts\n * try {\n * await loadBoard(file);\n * } catch (error) {\n * if (!(error instanceof OBFError)) throw error;\n * switch (error.info.code) {\n * case \"missing-resource\":\n * reupload(error.info.kind, error.info.path); // both fully typed\n * break;\n * case \"invalid-board\":\n * showIssues(error.info.issues);\n * break;\n * }\n * }\n * ```\n */\n\nimport { z } from \"zod\";\n\n/**\n * A single schema validation problem — Zod's issue shape, re-exported under a\n * domain name. `z.core.$ZodIssue` is the type Zod v4 designates for libraries\n * built on it (the bare `z.ZodIssue` is deprecated in its favor); aliasing it\n * gives consumers a stable OBF name without reaching into Zod's `core` export.\n */\nexport type OBFIssue = z.core.$ZodIssue;\n\n/**\n * Discriminated description of why an {@link OBFError} was thrown.\n *\n * Switch on `code`; each variant carries the fields relevant to it. When a\n * failure wraps an underlying error it lives on the standard `error.cause`,\n * never duplicated here. The only optional field is `invalid-board`'s\n * `boardId`, absent when validation runs on a value with no known id.\n */\nexport type OBFErrorInfo =\n // --- decoding (underlying parser/decompressor error on `error.cause`) ---\n /** Input was not parseable JSON. */\n | { code: \"not-json\"; source: \"board\" | \"manifest\" }\n /** An OBZ archive was expected, but the bytes are not a ZIP. */\n | { code: \"not-zip\" }\n /** A ZIP archive could not be decompressed. */\n | { code: \"unreadable-zip\" }\n /** An entry or the archive's declared uncompressed total exceeds a caller-supplied limit. */\n | {\n code: \"archive-too-large\";\n limit: \"maxEntrySize\" | \"maxTotalOriginalSize\";\n /** The cap that was exceeded, in bytes. */\n maxBytes: number;\n /** The declared size that exceeded it: the entry's size, or the running total. */\n declaredBytes: number;\n /** The archive entry whose declaration tripped the limit. */\n path: string;\n }\n /** The archive has more entries than a caller-supplied limit allows. */\n | {\n code: \"archive-too-large\";\n limit: \"maxEntries\";\n /** The cap that was exceeded, as an entry count. */\n maxEntries: number;\n /** The running entry count that exceeded it. */\n entryCount: number;\n /** The archive entry that tripped the limit. */\n path: string;\n }\n // --- validation (underlying `ZodError` on `error.cause`) ---\n /** A board failed schema validation. `boardId` is set when known. */\n | { code: \"invalid-board\"; boardId?: string; issues: readonly OBFIssue[] }\n /** A manifest failed schema validation. */\n | { code: \"invalid-manifest\"; issues: readonly OBFIssue[] }\n // --- archive structure (reading an .obz) ---\n /** The archive has no `manifest.json`. */\n | { code: \"missing-manifest\" }\n /** A board the manifest declares is absent from the archive. */\n | { code: \"missing-board\"; boardId: string; path: string }\n /** A board's `id` disagrees with the id the manifest declares for it. */\n | {\n code: \"board-id-mismatch\";\n path: string;\n declaredId: string;\n actualId: string;\n }\n // --- archive assembly (createOBZ) ---\n /** `rootBoardId` matches none of the supplied boards. */\n | { code: \"unknown-root\"; rootBoardId: string }\n /** Two supplied boards share the same `id`. */\n | { code: \"duplicate-board\"; boardId: string }\n /** A board declares a media `path` with no matching resource. */\n | {\n code: \"missing-resource\";\n kind: \"image\" | \"sound\";\n mediaId: string;\n path: string;\n }\n /** Two boards declare the same media id with different paths. */\n | {\n code: \"conflicting-paths\";\n kind: \"image\" | \"sound\";\n mediaId: string;\n paths: [string, string];\n }\n /** A supplied resource would overwrite a generated board or the manifest. */\n | { code: \"path-collision\"; path: string }\n /** The archive could not be compressed. */\n | { code: \"zip-failed\" }\n /** An internal invariant was violated — a bug in this library; please report. */\n | { code: \"internal\"; detail: string };\n\n/** Every `code` an {@link OBFError} can carry. */\nexport type OBFErrorCode = OBFErrorInfo[\"code\"];\n\n/**\n * The single error type thrown by `@shayc/open-board-format`.\n *\n * Branch on {@link OBFError.info} (a discriminated {@link OBFErrorInfo}) rather\n * than parsing {@link OBFError.message}. Any underlying error — a `JSON.parse`\n * failure, a `ZodError`, or an fflate error — is on the standard `error.cause`.\n */\nexport class OBFError extends Error {\n /** Structured, discriminated description of the failure. */\n readonly info: OBFErrorInfo;\n\n constructor(info: OBFErrorInfo, options?: { cause?: unknown }) {\n super(formatOBFError(info), options);\n this.name = \"OBFError\";\n this.info = info;\n }\n}\n\n/** Derive a human-readable message from an {@link OBFErrorInfo}. */\nfunction formatOBFError(info: OBFErrorInfo): string {\n switch (info.code) {\n case \"not-json\":\n return `Invalid ${info.source === \"manifest\" ? \"OBZ manifest\" : \"OBF\"}: not valid JSON`;\n case \"not-zip\":\n return \"Invalid OBZ: not a ZIP file\";\n case \"unreadable-zip\":\n return \"ZIP archive could not be read\";\n case \"archive-too-large\":\n return info.limit === \"maxEntries\"\n ? `Invalid OBZ: entry count reached ${info.entryCount} at \"${info.path}\", exceeding the ${info.maxEntries}-entry limit`\n : info.limit === \"maxEntrySize\"\n ? `Invalid OBZ: entry \"${info.path}\" declares ${info.declaredBytes} bytes uncompressed, exceeding the ${info.maxBytes}-byte per-entry limit`\n : `Invalid OBZ: declared uncompressed size reached ${info.declaredBytes} bytes at \"${info.path}\", exceeding the ${info.maxBytes}-byte total limit`;\n case \"invalid-board\": {\n const subject = info.boardId ? `board \"${info.boardId}\"` : \"board\";\n return `Invalid OBF ${subject}:\\n${prettifyIssues(info.issues)}`;\n }\n case \"invalid-manifest\":\n return `Invalid OBZ manifest:\\n${prettifyIssues(info.issues)}`;\n case \"missing-manifest\":\n return \"Invalid OBZ: missing manifest.json\";\n case \"missing-board\":\n return `Invalid OBZ: board \"${info.boardId}\" is declared in the manifest but missing at \"${info.path}\"`;\n case \"board-id-mismatch\":\n return `Invalid OBZ: board at \"${info.path}\" has id \"${info.actualId}\" but the manifest declares it as \"${info.declaredId}\"`;\n case \"unknown-root\":\n return `Invalid OBZ: rootBoardId \"${info.rootBoardId}\" does not match any supplied board`;\n case \"duplicate-board\":\n return `Invalid OBZ: duplicate board id \"${info.boardId}\" — board ids must be unique within a package`;\n case \"missing-resource\":\n return `Invalid OBZ: ${info.kind} \"${info.mediaId}\" references \"${info.path}\" but no matching resource was supplied`;\n case \"conflicting-paths\":\n return `Invalid OBZ: ${info.kind} id \"${info.mediaId}\" maps to conflicting paths \"${info.paths[0]}\" and \"${info.paths[1]}\"`;\n case \"path-collision\":\n return `Invalid OBZ: resource path \"${info.path}\" collides with a generated board or manifest entry`;\n case \"zip-failed\":\n return \"Failed to build ZIP archive\";\n case \"internal\":\n return `Internal error (please report): ${info.detail}`;\n /* v8 ignore start -- exhaustiveness guard: unreachable, enforced at compile time */\n default: {\n const _exhaustive: never = info;\n return _exhaustive;\n }\n /* v8 ignore stop */\n }\n}\n\n/** Render schema issues using Zod's pretty formatter. */\nfunction prettifyIssues(issues: readonly OBFIssue[]): string {\n return z.prettifyError(new z.ZodError([...issues]));\n}\n","/**\n * Parsing, validation, and serialization for single `.obf` board files.\n */\n\nimport { OBFError } from \"./errors\";\nimport type { OBFBoard } from \"./schema\";\nimport { OBFBoardSchema } from \"./schema\";\n\nconst UTF8_BOM = \"\\uFEFF\";\n\n/** Strip a leading UTF-8 BOM, which some editors silently prepend. */\nfunction stripBom(text: string): string {\n return text.startsWith(UTF8_BOM) ? text.slice(1) : text;\n}\n\n/**\n * Parse a JSON string into a validated OBF board.\n *\n * Strips an optional UTF-8 BOM prefix before parsing and throws a\n * descriptive error if the input is malformed or fails schema validation.\n *\n * @param json - The JSON string to parse.\n * @returns The validated board object.\n *\n * @throws {@link OBFError} with `info.code` `\"not-json\"` if the JSON is\n * malformed, or `\"invalid-board\"` if it fails schema validation.\n */\nexport function parseOBF(json: string): OBFBoard {\n const sanitized = stripBom(json);\n\n let rawBoard: unknown;\n\n try {\n rawBoard = JSON.parse(sanitized) as unknown;\n } catch (error) {\n throw new OBFError({ code: \"not-json\", source: \"board\" }, { cause: error });\n }\n\n return validateOBF(rawBoard);\n}\n\n/**\n * Read a `File` and parse its contents as a validated OBF board.\n *\n * This relies on the browser `File` API; for Node environments,\n * read the file to a string and pass it to {@link parseOBF} instead.\n *\n * @param file - A `File` handle pointing to an `.obf` file.\n * @returns The validated board object.\n *\n * @throws {@link OBFError} with `info.code` `\"not-json\"` if the file content is\n * malformed, or `\"invalid-board\"` if it fails schema validation.\n */\nexport async function loadOBF(file: File): Promise<OBFBoard> {\n const json = await file.text();\n return parseOBF(json);\n}\n\n/**\n * Validate an unknown value against the OBF board schema.\n *\n * @param data - The value to validate.\n * @returns The validated board object.\n *\n * @throws {@link OBFError} with `info.code` `\"invalid-board\"` if the value fails\n * schema validation. `info.issues` holds the underlying Zod issues.\n */\nexport function validateOBF(data: unknown): OBFBoard {\n const result = OBFBoardSchema.safeParse(data);\n\n if (!result.success) {\n throw new OBFError(\n { code: \"invalid-board\", issues: result.error.issues },\n { cause: result.error },\n );\n }\n\n return result.data;\n}\n\n/**\n * Stringify an OBF board to a pretty-printed JSON string.\n *\n * @param board - The board to stringify.\n * @returns A JSON string with two-space indentation.\n */\nexport function stringifyOBF(board: OBFBoard): string {\n return JSON.stringify(board, null, 2);\n}\n","/**\n * Minimal ZIP helpers over fflate: signature sniffing, unzip, and zip.\n */\n\nimport type { UnzipFileInfo } from \"fflate\";\nimport { unzip as fflateUnzip, zip as fflateZip } from \"fflate\";\nimport { OBFError } from \"./errors\";\n\n/**\n * First two bytes of every ZIP archive — the ASCII letters `PK`,\n * after Phil Katz, creator of the format.\n *\n * Only the 2-byte prefix is checked intentionally: this keeps the\n * test lightweight and sufficient for distinguishing ZIP from JSON.\n */\nconst ZIP_MAGIC = [0x50, 0x4b] as const;\n\n/** Balanced speed-vs-size deflate level, on fflate's 0–9 scale (0 = store). */\nconst COMPRESSION_LEVEL = 6;\n\n/**\n * Anything this package accepts as binary board data: a raw buffer, any\n * typed-array view into one (including Node's `Buffer`), or a `File`/`Blob`\n * handle.\n */\nexport type BinaryInput = File | Blob | ArrayBuffer | ArrayBufferView;\n\n/**\n * Normalize any {@link BinaryInput} shape into a plain `ArrayBuffer`.\n *\n * A view is sliced to its own window rather than returning `.buffer`\n * directly, since a `Uint8Array`/`Buffer` may cover only part of a larger,\n * possibly shared, underlying buffer.\n */\nexport async function toArrayBuffer(input: BinaryInput): Promise<ArrayBuffer> {\n if (input instanceof ArrayBuffer) {\n return input;\n }\n\n if (ArrayBuffer.isView(input)) {\n return input.buffer.slice(\n input.byteOffset,\n input.byteOffset + input.byteLength,\n ) as ArrayBuffer;\n }\n\n return input.arrayBuffer();\n}\n\n/**\n * Optional caps on declared uncompressed sizes, checked per entry against the\n * archive's ZIP metadata before that entry is inflated. Entries accepted\n * before a later entry trips a limit have already been inflated, but total\n * allocation stays bounded by the caps.\n */\nexport interface UnzipLimits {\n /** Max declared uncompressed size of any single entry, in bytes. */\n maxEntrySize?: number;\n /** Max sum of declared uncompressed sizes across all entries, in bytes. */\n maxTotalOriginalSize?: number;\n /** Max number of entries, counting directory entries the archive declares. */\n maxEntries?: number;\n}\n\n/** Options for {@link unzip} and the OBZ loaders that delegate to it. */\nexport interface UnzipOptions {\n /** Optional {@link UnzipLimits} enforced during extraction. */\n limits?: UnzipLimits;\n}\n\n/**\n * Decompress a ZIP archive into a map of file paths to raw bytes.\n *\n * Directory entries (paths ending in `/`, which some tools write explicitly\n * even though ZIP doesn't require them) are dropped — they carry no content\n * and this map is documented as file paths to bytes.\n *\n * @param archive - The ZIP archive as an `ArrayBuffer`.\n * @param options - Optional {@link UnzipOptions}. `options.limits` is checked\n * per entry against declared (metadata) sizes before that entry is\n * inflated. No limits are applied by default.\n * @returns A map of file paths to their decompressed content.\n *\n * @throws {@link OBFError} with `info.code` `\"unreadable-zip\"` if the archive is\n * corrupt or cannot be decompressed, or `\"archive-too-large\"` if a limit in\n * `options.limits` is exceeded.\n * @throws {@link TypeError} if a limit in `options.limits` is `NaN`.\n */\nexport function unzip(\n archive: ArrayBuffer,\n options?: UnzipOptions,\n): Promise<Map<string, Uint8Array>> {\n for (const key of [\n \"maxEntrySize\",\n \"maxTotalOriginalSize\",\n \"maxEntries\",\n ] as const) {\n const value = options?.limits?.[key];\n if (value !== undefined && Number.isNaN(value)) {\n throw new TypeError(`limits.${key} must not be NaN`);\n }\n }\n\n return new Promise((resolve, reject) => {\n const compressed = new Uint8Array(archive);\n const { maxEntrySize, maxTotalOriginalSize, maxEntries } =\n options?.limits ?? {};\n\n let limitError: OBFError | undefined;\n let settled = false;\n let totalDeclared = 0;\n let entryCount = 0;\n\n const filter = (file: UnzipFileInfo): boolean => {\n if (limitError) {\n return false; // limit tripped: skip the rest cheaply\n }\n\n entryCount += 1;\n if (maxEntries !== undefined && entryCount > maxEntries) {\n limitError = new OBFError({\n code: \"archive-too-large\",\n limit: \"maxEntries\",\n maxEntries,\n entryCount,\n path: file.name,\n });\n return false;\n }\n\n if (maxEntrySize !== undefined && file.originalSize > maxEntrySize) {\n limitError = new OBFError({\n code: \"archive-too-large\",\n limit: \"maxEntrySize\",\n maxBytes: maxEntrySize,\n declaredBytes: file.originalSize,\n path: file.name,\n });\n return false;\n }\n\n totalDeclared += file.originalSize;\n if (\n maxTotalOriginalSize !== undefined &&\n totalDeclared > maxTotalOriginalSize\n ) {\n limitError = new OBFError({\n code: \"archive-too-large\",\n limit: \"maxTotalOriginalSize\",\n maxBytes: maxTotalOriginalSize,\n declaredBytes: totalDeclared,\n path: file.name,\n });\n return false;\n }\n\n return true;\n };\n\n const terminate = fflateUnzip(\n compressed,\n options?.limits ? { filter } : {},\n (error, entries) => {\n if (settled) {\n return;\n }\n settled = true;\n\n if (error) {\n reject(new OBFError({ code: \"unreadable-zip\" }, { cause: error }));\n return;\n }\n\n if (limitError) {\n reject(limitError);\n return;\n }\n\n const pathToBytes = new Map(\n Object.entries(entries).filter(([path]) => !path.endsWith(\"/\")),\n );\n\n resolve(pathToBytes);\n },\n );\n\n if (limitError) {\n const error = limitError;\n // Deliberate: this can pre-empt an in-flight entry's own corruption error, surfacing archive-too-large instead of unreadable-zip.\n terminate(); // kill any dispatched async inflate workers\n // Deferred so an archive error fflate queued during its sync pass settles first.\n queueMicrotask(() => {\n if (!settled) {\n settled = true;\n reject(error);\n }\n });\n }\n });\n}\n\n/**\n * Compress a map of file paths and contents into a single ZIP archive.\n *\n * Accepts both `Uint8Array` and `ArrayBuffer` values so callers can\n * pass the output of {@link unzip} directly or supply raw `ArrayBuffer`s\n * without converting first.\n *\n * @param entries - A map of file paths to their content bytes.\n * @returns The compressed archive as a `Uint8Array`.\n *\n * @throws {@link OBFError} with `info.code` `\"zip-failed\"` if fflate fails to\n * compress an entry.\n */\nexport function zip(\n entries: Map<string, Uint8Array | ArrayBuffer>,\n): Promise<Uint8Array> {\n return new Promise((resolve, reject) => {\n const pathToBytes: Record<string, Uint8Array> = {};\n\n for (const [path, content] of entries) {\n pathToBytes[path] =\n content instanceof Uint8Array ? content : new Uint8Array(content);\n }\n\n fflateZip(pathToBytes, { level: COMPRESSION_LEVEL }, (error, result) => {\n /* v8 ignore start -- defensive: fflate does not error on valid byte input */\n if (error) {\n reject(new OBFError({ code: \"zip-failed\" }, { cause: error }));\n return;\n }\n /* v8 ignore stop */\n\n resolve(result);\n });\n });\n}\n\n/**\n * Test whether an `ArrayBuffer` begins with the two-byte ZIP magic\n * prefix (`PK`).\n *\n * @param archive - The buffer to inspect.\n * @returns `true` if the buffer starts with the ZIP signature.\n */\nexport function isZip(archive: ArrayBuffer): boolean {\n const bytes = new Uint8Array(archive);\n\n return (\n bytes.length >= ZIP_MAGIC.length &&\n ZIP_MAGIC.every((byte, index) => bytes[index] === byte)\n );\n}\n","/**\n * Creation and extraction of `.obz` board packages.\n */\n\nimport { OBFError } from \"./errors\";\nimport { parseOBF } from \"./obf\";\nimport type { OBFBoard, OBFManifest } from \"./schema\";\nimport { OBFBoardSchema, OBFManifestSchema } from \"./schema\";\nimport type { BinaryInput, UnzipOptions } from \"./zip\";\nimport { isZip, toArrayBuffer, unzip, zip } from \"./zip\";\n\n/**\n * Fully extracted contents of an `.obz` archive.\n */\nexport interface ParsedOBZ {\n /** The package's table of contents. */\n manifest: OBFManifest;\n /** Validated board objects keyed by board ID. */\n boards: Map<string, OBFBoard>;\n /**\n * The package's entry-point board — the one `manifest.root` points at,\n * already resolved. Same object as `boards.get(rootBoard.id)`.\n */\n rootBoard: OBFBoard;\n /**\n * Raw bytes for every entry in the archive, keyed by archive path —\n * including `manifest.json` and the `.obf` boards as well as media\n * such as images and sounds.\n */\n resources: Map<string, Uint8Array>;\n}\n\n/**\n * Read a `File` and extract its contents as a parsed OBZ package.\n *\n * A thin convenience wrapper — {@link extractOBZ} accepts a `File` directly,\n * so this exists only for the naming symmetry with {@link loadOBF}.\n *\n * @param file - A `File` handle pointing to an `.obz` archive.\n * @param options - Optional {@link UnzipOptions} on declared uncompressed sizes.\n * @returns The parsed manifest, boards, root board, and binary resources.\n *\n * @throws {@link OBFError} — the same failures as {@link extractOBZ}, which\n * this delegates to.\n * @throws {@link TypeError} if a limit in `options.limits` is `NaN`.\n */\nexport async function loadOBZ(\n file: File,\n options?: UnzipOptions,\n): Promise<ParsedOBZ> {\n return extractOBZ(file, options);\n}\n\n/**\n * Decompress an OBZ archive and return its manifest, boards, and resources.\n *\n * @param archive - The OBZ archive as a `File`, `Blob`, `ArrayBuffer`, or\n * `ArrayBufferView` (e.g. a Node `Buffer`).\n * @param options - Optional {@link UnzipOptions}. `options.limits` caps\n * declared uncompressed sizes, checked before inflation. No limits are\n * applied by default.\n * @returns A {@link ParsedOBZ} with the archive's manifest, boards, root\n * board, and resources.\n *\n * @throws {@link OBFError}; branch on `info.code`: `\"not-zip\"`,\n * `\"unreadable-zip\"`, `\"archive-too-large\"` (a limit in `options.limits` is\n * exceeded), `\"missing-manifest\"`, `\"not-json\"` or `\"invalid-manifest\"`\n * (bad manifest), `\"missing-board\"`, `\"board-id-mismatch\"`, or\n * `\"invalid-board\"` (a board fails validation).\n * @throws {@link TypeError} if a limit in `options.limits` is `NaN`.\n */\nexport async function extractOBZ(\n archive: BinaryInput,\n options?: UnzipOptions,\n): Promise<ParsedOBZ> {\n const buffer = await toArrayBuffer(archive);\n\n if (!isZip(buffer)) {\n throw new OBFError({ code: \"not-zip\" });\n }\n\n const entries = await unzip(buffer, options);\n\n const manifest = extractManifest(entries);\n const { boards, rootBoard } = extractBoards(manifest, entries);\n\n return { manifest, boards, rootBoard, resources: entries };\n}\n\n/**\n * Parse and validate an OBZ manifest — the table of contents that maps\n * board IDs to their file paths within the archive.\n *\n * @param json - A JSON string representing the manifest.\n * @returns The validated manifest object.\n *\n * @throws {@link OBFError} with `info.code` `\"not-json\"` if the JSON is\n * malformed, or `\"invalid-manifest\"` if it fails schema validation.\n */\nexport function parseManifest(json: string): OBFManifest {\n let data: unknown;\n\n try {\n data = JSON.parse(json) as unknown;\n } catch (error) {\n throw new OBFError(\n { code: \"not-json\", source: \"manifest\" },\n { cause: error },\n );\n }\n\n const result = OBFManifestSchema.safeParse(data);\n\n if (!result.success) {\n throw new OBFError(\n { code: \"invalid-manifest\", issues: result.error.issues },\n { cause: result.error },\n );\n }\n\n return result.data;\n}\n\n/**\n * Bundle boards and optional resources into a compressed OBZ archive.\n *\n * A manifest is generated automatically from the supplied boards,\n * using the `rootBoardId` to designate the entry-point board.\n *\n * Every failure is an {@link OBFError}; branch on `info.code`.\n *\n * @param boards - The boards to include in the archive.\n * @param rootBoardId - The ID of the board that serves as the archive's entry point.\n * @param resources - Optional map of file paths to binary content (images, sounds, etc.).\n * @returns A `Blob` containing the compressed OBZ archive.\n *\n * @throws {@link OBFError} `\"unknown-root\"` if `rootBoardId` does not match any of the supplied boards.\n * @throws {@link OBFError} `\"duplicate-board\"` if two supplied boards share the same ID.\n * @throws {@link OBFError} `\"invalid-board\"` if a supplied board fails schema validation.\n * @throws {@link OBFError} `\"conflicting-paths\"` if two boards declare the same media ID with conflicting paths.\n * @throws {@link OBFError} `\"missing-resource\"` if a board declares an image or sound `path` with no matching entry in `resources`.\n * @throws {@link OBFError} `\"path-collision\"` if a `resources` entry would overwrite the generated `manifest.json` or a board file.\n */\nexport async function createOBZ(\n boards: OBFBoard[],\n rootBoardId: string,\n resources?: Map<string, Uint8Array | ArrayBuffer>,\n): Promise<Blob> {\n if (!boards.some((board) => board.id === rootBoardId)) {\n throw new OBFError({ code: \"unknown-root\", rootBoardId });\n }\n\n const seenBoardIds = new Set<string>();\n for (const board of boards) {\n if (seenBoardIds.has(board.id)) {\n throw new OBFError({ code: \"duplicate-board\", boardId: board.id });\n }\n seenBoardIds.add(board.id);\n }\n\n const entries = new Map<string, Uint8Array | ArrayBuffer>();\n\n const boardPaths = Object.fromEntries(\n boards.map((board) => [board.id, boardPath(board.id)]),\n );\n\n const imagePaths = collectMediaPaths(boards, \"images\");\n const soundPaths = collectMediaPaths(boards, \"sounds\");\n\n const manifestResult = OBFManifestSchema.safeParse({\n format: \"open-board-0.1\",\n root: boardPath(rootBoardId),\n paths: {\n boards: boardPaths,\n images: imagePaths,\n sounds: soundPaths,\n },\n });\n\n /* v8 ignore start -- defensive: the manifest is built from already-validated inputs */\n if (!manifestResult.success) {\n throw new OBFError(\n { code: \"internal\", detail: \"generated manifest failed validation\" },\n { cause: manifestResult.error },\n );\n }\n /* v8 ignore stop */\n\n const manifest = manifestResult.data;\n\n const encoder = new TextEncoder();\n\n entries.set(\n \"manifest.json\",\n encoder.encode(JSON.stringify(manifest, null, 2)),\n );\n\n for (const board of boards) {\n const result = OBFBoardSchema.safeParse(board);\n if (!result.success) {\n throw new OBFError(\n {\n code: \"invalid-board\",\n boardId: board.id,\n issues: result.error.issues,\n },\n { cause: result.error },\n );\n }\n\n entries.set(\n boardPaths[board.id]!,\n encoder.encode(JSON.stringify(result.data, null, 2)),\n );\n }\n\n if (resources) {\n for (const [path, bytes] of resources) {\n if (entries.has(path)) {\n throw new OBFError({ code: \"path-collision\", path });\n }\n entries.set(path, bytes);\n }\n }\n\n assertPathsPresent(\"image\", imagePaths, entries);\n assertPathsPresent(\"sound\", soundPaths, entries);\n\n const compressed = await zip(entries);\n return new Blob([compressed], { type: \"application/zip\" });\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Derive a board's archive path from its id.\n *\n * Board ids are spec-legal as any non-empty string, but archive paths give\n * `/` and `\\` structural meaning. Percent-encoding the id keeps the mapping\n * deterministic and collision-free without rejecting any id the schema\n * already allows — a `/` or `..` in the id just becomes part of a filename,\n * never a path segment.\n */\nfunction boardPath(id: string): string {\n return `boards/${encodeURIComponent(id)}.obf`;\n}\n\n/**\n * Walk every board's media collection and produce the `{ id -> path }` map\n * the spec calls \"redundant but still required\" for the OBZ manifest.\n *\n * Throws when two boards declare the same media ID with conflicting paths\n * — a silent OBZ that points at a non-existent file is worse than a clear error.\n */\nfunction collectMediaPaths(\n boards: OBFBoard[],\n kind: \"images\" | \"sounds\",\n): Record<string, string> {\n const paths: Record<string, string> = {};\n\n for (const board of boards) {\n for (const media of board[kind] ?? []) {\n if (media.path === undefined) {\n continue;\n }\n\n const existing = paths[media.id];\n if (existing !== undefined && existing !== media.path) {\n throw new OBFError({\n code: \"conflicting-paths\",\n kind: kind === \"images\" ? \"image\" : \"sound\",\n mediaId: media.id,\n paths: [existing, media.path],\n });\n }\n paths[media.id] = media.path;\n }\n }\n\n return paths;\n}\n\n/**\n * Assert that every media path the generated manifest declares exists as an\n * archive entry — the same contract {@link extractOBZ} assumes when reading.\n *\n * Only media that declared a `path` reach this check, so `url`/`data`-only\n * media are never flagged.\n */\nfunction assertPathsPresent(\n kind: \"image\" | \"sound\",\n paths: Record<string, string>,\n entries: Map<string, Uint8Array | ArrayBuffer>,\n): void {\n for (const [id, path] of Object.entries(paths)) {\n if (!entries.has(path)) {\n throw new OBFError({\n code: \"missing-resource\",\n kind,\n mediaId: id,\n path,\n });\n }\n }\n}\n\nfunction extractManifest(entries: Map<string, Uint8Array>): OBFManifest {\n const manifestBytes = entries.get(\"manifest.json\");\n\n if (!manifestBytes) {\n throw new OBFError({ code: \"missing-manifest\" });\n }\n\n const manifestJson = new TextDecoder().decode(manifestBytes);\n return parseManifest(manifestJson);\n}\n\nfunction extractBoards(\n manifest: OBFManifest,\n entries: Map<string, Uint8Array>,\n): { boards: Map<string, OBFBoard>; rootBoard: OBFBoard } {\n const boards = new Map<string, OBFBoard>();\n let rootBoard: OBFBoard | undefined;\n\n for (const [id, path] of Object.entries(manifest.paths.boards)) {\n const boardBytes = entries.get(path);\n\n if (!boardBytes) {\n throw new OBFError({ code: \"missing-board\", boardId: id, path });\n }\n\n const boardJson = new TextDecoder().decode(boardBytes);\n const board = parseOBF(boardJson);\n\n if (board.id !== id) {\n throw new OBFError({\n code: \"board-id-mismatch\",\n path,\n declaredId: id,\n actualId: board.id,\n });\n }\n\n boards.set(id, board);\n\n if (path === manifest.root) {\n rootBoard = board;\n }\n }\n\n // `OBFManifestSchema` requires `root` to be one of `paths.boards`, so the loop\n // above always assigns `rootBoard` for the validated manifests we receive.\n /* v8 ignore start -- defensive: OBFManifestSchema guarantees root ∈ paths.boards */\n if (!rootBoard) {\n throw new OBFError({\n code: \"internal\",\n detail: `root board \"${manifest.root}\" not found in paths.boards`,\n });\n }\n /* v8 ignore stop */\n\n return { boards, rootBoard };\n}\n","/**\n * Format-agnostic loading of `.obf` boards and `.obz` packages.\n */\n\nimport { parseOBF } from \"./obf\";\nimport type { ParsedOBZ } from \"./obz\";\nimport { extractOBZ } from \"./obz\";\nimport type { OBFBoard } from \"./schema\";\nimport type { BinaryInput, UnzipOptions } from \"./zip\";\nimport { isZip, toArrayBuffer } from \"./zip\";\n\n/**\n * Result of {@link loadBoard} — a discriminated union over the two file\n * shapes the Open Board Format defines.\n *\n * Switch on `format` to narrow:\n *\n * ```ts\n * const loaded = await loadBoard(file);\n * if (loaded.format === \"obz\") {\n * loaded.archive.rootBoard; // home board of the ParsedOBZ archive\n * } else {\n * loaded.board; // OBFBoard\n * }\n * ```\n */\nexport type LoadedBoard =\n { format: \"obz\"; archive: ParsedOBZ } | { format: \"obf\"; board: OBFBoard };\n\n/**\n * Detect whether the input is a single OBF board or an OBZ package and load it\n * accordingly.\n *\n * Input that begins with the ZIP magic prefix is treated as an `.obz` package;\n * anything else is parsed as an `.obf` board. The input is read once, so\n * consumers can accept either format from a single drag-and-drop, file picker,\n * or fetch response without inspecting the file extension or re-deriving the\n * OBF-vs-OBZ distinction themselves.\n *\n * @param input - A `File`, `Blob`, `ArrayBuffer`, or `ArrayBufferView`\n * (e.g. a Node `Buffer`) holding `.obf` or `.obz` content.\n * @param options - Optional {@link UnzipOptions}. Applies only when the input\n * is an OBZ archive; ignored for `.obf` JSON.\n * @returns A discriminated union tagged by `format`.\n *\n * @throws {@link OBFError} — the OBZ failures of {@link extractOBZ} when the\n * input is an archive, or the OBF failures of {@link parseOBF} otherwise.\n * Branch on `error.info.code`.\n * @throws {@link TypeError} if a limit in `options.limits` is `NaN` and the\n * input is an OBZ archive.\n */\nexport async function loadBoard(\n input: BinaryInput,\n options?: UnzipOptions,\n): Promise<LoadedBoard> {\n const buffer = await toArrayBuffer(input);\n\n if (isZip(buffer)) {\n return { format: \"obz\", archive: await extractOBZ(buffer, options) };\n }\n\n return { format: \"obf\", board: parseOBF(new TextDecoder().decode(buffer)) };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["_exhaustive","fflateUnzip"],"sources":["../src/schema.ts","../src/errors.ts","../src/obf.ts","../src/zip.ts","../src/obz.ts","../src/load-board.ts"],"sourcesContent":["/**\n * Zod schemas for the Open Board Format (OBF) data model.\n *\n * Official OBF specification: https://www.openboardformat.org/docs\n */\n\nimport { z } from \"zod\";\n\n/** Optional URL that treats empty strings as undefined. */\nconst OBFOptionalUrlSchema = z\n .union([z.url(), z.literal(\"\")])\n .transform((val) => (val === \"\" ? undefined : val))\n .optional();\n\n/** Optional email that treats empty strings as undefined. */\nconst OBFOptionalEmailSchema = z\n .union([z.email(), z.literal(\"\")])\n .transform((val) => (val === \"\" ? undefined : val))\n .optional();\n\n/** Optional ID that treats empty strings as undefined. */\nconst OBFOptionalIDSchema = z\n .union([z.string(), z.number()])\n .transform((val) => {\n const str = String(val);\n return str === \"\" ? undefined : str;\n })\n .optional();\n\n/** Unique board-element identifier, coerced to a non-empty string. */\nexport const OBFIDSchema = z\n .union([z.string(), z.number()])\n .transform((val) => String(val))\n .pipe(z.string().min(1));\n\n/** Unique board-element identifier, coerced to a non-empty string. See {@link OBFIDSchema}. */\nexport type OBFID = z.infer<typeof OBFIDSchema>;\n\n/** Format version of the Open Board Format, e.g., `open-board-0.1`. */\nexport const OBFFormatVersionSchema = z.string().regex(/^open-board-.+$/);\n\n/** Format version of the Open Board Format, e.g., `open-board-0.1`. See {@link OBFFormatVersionSchema}. */\nexport type OBFFormatVersion = z.infer<typeof OBFFormatVersionSchema>;\n\n/**\n * Locale identifier, typically a BCP 47 language tag (e.g., `en`, `en-US`,\n * `fr-CA`). Not strictly validated — any string is accepted.\n */\nexport const OBFLocaleCodeSchema = z.string();\n\n/** Locale identifier, typically a BCP 47 language tag; accepts any string. See {@link OBFLocaleCodeSchema}. */\nexport type OBFLocaleCode = z.infer<typeof OBFLocaleCodeSchema>;\n\n/**\n * Translations for a single locale, keyed by the source string,\n * e.g., `{ \"hello\": \"hola\" }`.\n */\nexport const OBFLocalizedStringsSchema = z.record(z.string(), z.string());\n\n/** Translations for a single locale, keyed by the source string. See {@link OBFLocalizedStringsSchema}. */\nexport type OBFLocalizedStrings = z.infer<typeof OBFLocalizedStringsSchema>;\n\n/**\n * Locale-keyed dictionary of translated strings,\n * e.g., `{ en: { greeting: \"Hello\" }, fr: { greeting: \"Bonjour\" } }`.\n */\nexport const OBFStringsSchema = z.record(z.string(), OBFLocalizedStringsSchema);\n\n/** Locale-keyed dictionary of translated strings. See {@link OBFStringsSchema}. */\nexport type OBFStrings = z.infer<typeof OBFStringsSchema>;\n\n/**\n * Spelling action: a `+` prefix followed by the text to append,\n * e.g., `+hello`.\n */\nexport const OBFSpellingActionSchema = z.string().regex(/^\\+.+$/);\n\n/** Spelling action: a `+` prefix followed by the text to append, e.g., `+hello`. See {@link OBFSpellingActionSchema}. */\nexport type OBFSpellingAction = z.infer<typeof OBFSpellingActionSchema>;\n\n/**\n * Specialty action prefixed with `:`, e.g., `:clear`.\n * Custom extensions use the `:ext_` prefix.\n */\nexport const OBFSpecialtyActionSchema = z\n .string()\n .regex(/^:[a-z][a-z0-9_-]*$/i);\n\n/** Specialty action prefixed with `:`, e.g., `:clear`. See {@link OBFSpecialtyActionSchema}. */\nexport type OBFSpecialtyAction = z.infer<typeof OBFSpecialtyActionSchema>;\n\n/** Union of spelling and specialty actions that a button can trigger. */\nexport const OBFButtonActionSchema = z.union([\n OBFSpellingActionSchema,\n OBFSpecialtyActionSchema,\n]);\n\n/** Union of spelling and specialty actions that a button can trigger. See {@link OBFButtonActionSchema}. */\nexport type OBFButtonAction = z.infer<typeof OBFButtonActionSchema>;\n\n/** License terms and attribution for a resource. */\nexport const OBFLicenseSchema = z.looseObject({\n /** Type of the license, e.g., `CC-BY-SA`. */\n type: z.string(),\n /** URL to the license terms. */\n copyright_notice_url: OBFOptionalUrlSchema,\n /** Source URL of the resource. */\n source_url: OBFOptionalUrlSchema,\n /** Name of the author. */\n author_name: z.string().optional(),\n /** URL of the author's webpage. */\n author_url: OBFOptionalUrlSchema,\n /** Email address of the author. */\n author_email: OBFOptionalEmailSchema,\n});\n\n/** License terms and attribution for a resource. See {@link OBFLicenseSchema}. */\nexport type OBFLicense = z.infer<typeof OBFLicenseSchema>;\n\n/**\n * Common properties for media resources (images and sounds).\n *\n * Resolve multiple references in this order:\n *\n * 1. `data`\n * 2. `path`\n * 3. `url`\n *\n * `data_url` is not part of this fallback chain — it is an API endpoint for\n * retrieving information about the resource, not an alternative source of\n * the media bytes.\n */\nexport const OBFMediaSchema = z.looseObject({\n /** Unique identifier for the media resource. */\n id: OBFIDSchema,\n /** Media data inlined as a `data:` URI. */\n data: z.string().optional(),\n /** Path to the media file within an `.obz` package. */\n path: z.string().optional(),\n /**\n * URL of an API endpoint for fetching the media programmatically —\n * not a `data:` URI (that is `data`).\n */\n data_url: OBFOptionalUrlSchema,\n /** URL to the media resource. */\n url: OBFOptionalUrlSchema,\n /** MIME type of the media, e.g., `image/png`, `audio/mpeg`. */\n content_type: z.string().optional(),\n /** Licensing information for the media. */\n license: OBFLicenseSchema.optional(),\n});\n\n/** Common properties for media resources (images and sounds). See {@link OBFMediaSchema}. */\nexport type OBFMedia = z.infer<typeof OBFMediaSchema>;\n\n/** Reference to a symbol in a proprietary symbol set (e.g., SymbolStix). */\nexport const OBFSymbolInfoSchema = z.looseObject({\n /** Name of the symbol set, e.g., `symbolstix`. */\n set: z.string(),\n /** Filename of the symbol within the set. */\n filename: z.string(),\n});\n\n/** Reference to a symbol in a proprietary symbol set. See {@link OBFSymbolInfoSchema}. */\nexport type OBFSymbolInfo = z.infer<typeof OBFSymbolInfoSchema>;\n\n/**\n * Image resource, extending {@link OBFMediaSchema} with optional\n * symbol and dimension properties.\n *\n * Resolve multiple image sources in this order:\n *\n * 1. `data`\n * 2. `path`\n * 3. `url`\n * 4. `symbol`\n */\nexport const OBFImageSchema = OBFMediaSchema.extend({\n /** Information about a symbol from a proprietary symbol set. */\n symbol: OBFSymbolInfoSchema.optional(),\n /** Width of the image in pixels. */\n width: z.number().optional(),\n /** Height of the image in pixels. */\n height: z.number().optional(),\n});\n\n/** Image resource with optional symbol and dimension properties. See {@link OBFImageSchema}. */\nexport type OBFImage = z.infer<typeof OBFImageSchema>;\n\n/**\n * Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.\n */\nexport const OBFSoundSchema = OBFMediaSchema;\n\n/** Audio resource, identical to {@link OBFMediaSchema}. See {@link OBFSoundSchema}. */\nexport type OBFSound = z.infer<typeof OBFSoundSchema>;\n\n/** Reference to another board, resolved by ID, path, or URL. */\nexport const OBFLoadBoardSchema = z.looseObject({\n /** Unique identifier of the board to load. */\n id: OBFOptionalIDSchema,\n /** Name of the board to load. */\n name: z.string().optional(),\n /**\n * URL of an API endpoint for fetching the board programmatically —\n * not a `data:` URI.\n */\n data_url: OBFOptionalUrlSchema,\n /** URL to access the board via a web browser. */\n url: OBFOptionalUrlSchema,\n /** Path to the board within an `.obz` package. */\n path: z.string().optional(),\n});\n\n/** Reference to another board, resolved by ID, path, or URL. See {@link OBFLoadBoardSchema}. */\nexport type OBFLoadBoard = z.infer<typeof OBFLoadBoardSchema>;\n\n/**\n * Interactive board element, optionally linked to images, sounds, and actions.\n */\nexport const OBFButtonSchema = z\n .looseObject({\n /** Unique identifier for the button. */\n id: OBFIDSchema,\n /** Label text displayed on the button. */\n label: z.string().optional(),\n /** Alternative text for vocalization when the button is activated. */\n vocalization: z.string().optional(),\n /** Identifier of the image associated with the button. */\n image_id: OBFOptionalIDSchema,\n /** Identifier of the sound associated with the button. */\n sound_id: OBFOptionalIDSchema,\n /**\n * Action triggered by the button. When `actions` is also set, this is\n * the single-action fallback for apps that support one action per button.\n */\n action: OBFButtonActionSchema.optional(),\n /**\n * Multiple actions executed in order. Apps that support it should\n * prefer this over the single `action` fallback.\n */\n actions: z.array(OBFButtonActionSchema).optional(),\n /** Information to load another board when this button is activated. */\n load_board: OBFLoadBoardSchema.optional(),\n /**\n * Background color, typically an `rgb()` or `rgba()` value. Accepts any\n * string.\n */\n background_color: z.string().optional(),\n /**\n * Border color, typically an `rgb()` or `rgba()` value. Accepts any string.\n */\n border_color: z.string().optional(),\n /** Vertical position for absolute positioning, from 0 to 1. */\n top: z.number().min(0).max(1).optional(),\n /** Horizontal position for absolute positioning, from 0 to 1. */\n left: z.number().min(0).max(1).optional(),\n /** Width of the button for absolute positioning, from 0 to 1. */\n width: z.number().min(0).max(1).optional(),\n /** Height of the button for absolute positioning, from 0 to 1. */\n height: z.number().min(0).max(1).optional(),\n })\n .refine(\n (b) => {\n const set = [b.top, b.left, b.width, b.height].filter(\n (v) => v !== undefined,\n );\n return set.length === 0 || set.length === 4;\n },\n {\n message:\n \"Absolute positioning requires all of top, left, width, and height (or none)\",\n },\n );\n\n/** Interactive board element, optionally linked to images, sounds, and actions. See {@link OBFButtonSchema}. */\nexport type OBFButton = z.infer<typeof OBFButtonSchema>;\n\n/**\n * Upper bound on grid dimensions. A board only needs these to lay out cells;\n * a consumer allocates rows × columns, so an unbounded value (e.g. 1e9) would\n * exhaust memory. 100 is generous headroom over any real AAC board (~15–20)\n * and caps the hostile worst case at 100 × 100 cells.\n */\nconst MAX_GRID_ROWS = 100;\nconst MAX_GRID_COLUMNS = 100;\n\n/** Row-and-column layout that arranges buttons by their IDs. */\nexport const OBFGridSchema = z\n .looseObject({\n /** Number of rows in the grid. */\n rows: z.number().int().min(1).max(MAX_GRID_ROWS),\n /** Number of columns in the grid. */\n columns: z.number().int().min(1).max(MAX_GRID_COLUMNS),\n /** Button IDs by row; `null` marks an empty cell. */\n order: z.array(z.array(z.union([OBFIDSchema, z.null()]))),\n })\n .refine((g) => g.order.length === g.rows, {\n message: \"Grid order length must match rows\",\n })\n .refine((g) => g.order.every((row) => row.length === g.columns), {\n message: \"Each grid row must have length equal to columns\",\n });\n\n/** Row-and-column layout that arranges buttons by their IDs. See {@link OBFGridSchema}. */\nexport type OBFGrid = z.infer<typeof OBFGridSchema>;\n\n/**\n * Root object of an `.obf` file: the complete definition of a single communication board.\n */\nexport const OBFBoardSchema = z.looseObject({\n /** Format version of the Open Board Format, e.g., `open-board-0.1`. */\n format: OBFFormatVersionSchema,\n /** Unique identifier for the board. */\n id: OBFIDSchema,\n /** Locale identifier, typically a BCP 47 language tag such as `en` or `en-US`. */\n locale: OBFLocaleCodeSchema.optional(),\n /** List of buttons on the board. */\n buttons: z.array(OBFButtonSchema),\n /** URL where the board can be accessed or downloaded. */\n url: OBFOptionalUrlSchema,\n /** Name of the board. */\n name: z.string().optional(),\n /** Description of the board in HTML format. */\n description_html: z.string().optional(),\n /** Grid layout information for arranging buttons. */\n grid: OBFGridSchema,\n /** List of images used in the board. */\n images: z.array(OBFImageSchema).optional(),\n /** List of sounds used in the board. */\n sounds: z.array(OBFSoundSchema).optional(),\n /** Licensing information for the board. */\n license: OBFLicenseSchema.optional(),\n /** String translations for multiple locales. */\n strings: OBFStringsSchema.optional(),\n});\n\n/** Root object of an `.obf` file: the complete definition of a single communication board. See {@link OBFBoardSchema}. */\nexport type OBFBoard = z.infer<typeof OBFBoardSchema>;\n\n/**\n * Table of contents for an `.obz` package, mapping resource IDs to their archive paths.\n */\nexport const OBFManifestSchema = z\n .looseObject({\n /** Format version of the Open Board Format, e.g., `open-board-0.1`. */\n format: OBFFormatVersionSchema,\n /** Path to the root board within the `.obz` package. */\n root: z.string(),\n /** Mapping of IDs to paths for boards, images, and sounds. */\n paths: z.looseObject({\n /** Mapping of board IDs to their file paths. */\n boards: z.record(z.string(), z.string()),\n /** Mapping of image IDs to their file paths. */\n images: z.record(z.string(), z.string()).optional(),\n /** Mapping of sound IDs to their file paths. */\n sounds: z.record(z.string(), z.string()).optional(),\n }),\n })\n .refine((m) => Object.values(m.paths.boards).includes(m.root), {\n message: \"root must be listed in paths.boards\",\n path: [\"root\"],\n });\n\n/** Table of contents for an `.obz` package, mapping resource IDs to their archive paths. See {@link OBFManifestSchema}. */\nexport type OBFManifest = z.infer<typeof OBFManifestSchema>;\n","/**\n * Typed errors for `@shayc/open-board-format`.\n *\n * Expected parsing, validation, and archive failures use {@link OBFError}. Each\n * carries a discriminated {@link OBFErrorInfo} on its `info` property. Switch\n * on `error.info.code` for the structured context; the human-readable\n * `message` is derived from `info` and is not part of the stable contract.\n *\n * ```ts\n * try {\n * await loadBoard(file);\n * } catch (error) {\n * if (!(error instanceof OBFError)) throw error;\n * switch (error.info.code) {\n * case \"missing-resource\":\n * reupload(error.info.kind, error.info.path); // both fully typed\n * break;\n * case \"invalid-board\":\n * showIssues(error.info.issues);\n * break;\n * }\n * }\n * ```\n */\n\nimport { z } from \"zod\";\n\n/**\n * A single schema validation problem — Zod's issue shape, re-exported under a\n * domain name. `z.core.$ZodIssue` is the type Zod v4 designates for libraries\n * built on it (the bare `z.ZodIssue` is deprecated in its favor); aliasing it\n * gives consumers a stable OBF name without reaching into Zod's `core` export.\n */\nexport type OBFIssue = z.core.$ZodIssue;\n\n/**\n * Discriminated description of why an {@link OBFError} was thrown.\n *\n * Switch on `code`; each variant carries the fields relevant to it. When a\n * failure wraps an underlying error it lives on the standard `error.cause`,\n * never duplicated here. The only optional field is `invalid-board`'s\n * `boardId`, absent when validation runs before a board `id` is known.\n */\nexport type OBFErrorInfo =\n // --- decoding (underlying parser/decompressor error on `error.cause`) ---\n /** Input was not parseable JSON. */\n | { code: \"not-json\"; source: \"board\" | \"manifest\" }\n /** An OBZ archive was expected, but the bytes are not a ZIP. */\n | { code: \"not-zip\" }\n /** A ZIP archive could not be decompressed. */\n | { code: \"unreadable-zip\" }\n /** An entry or the archive's declared uncompressed total exceeds a caller-supplied limit. */\n | {\n code: \"archive-too-large\";\n limit: \"maxEntrySize\" | \"maxTotalOriginalSize\";\n /** The cap that was exceeded, in bytes. */\n maxBytes: number;\n /** The declared size that exceeded it: the entry's size, or the running total. */\n declaredBytes: number;\n /** The archive entry whose declaration tripped the limit. */\n path: string;\n }\n /** The archive has more entries than a caller-supplied limit allows. */\n | {\n code: \"archive-too-large\";\n limit: \"maxEntries\";\n /** The cap that was exceeded, as an entry count. */\n maxEntries: number;\n /** The running entry count that exceeded it. */\n entryCount: number;\n /** The archive entry that tripped the limit. */\n path: string;\n }\n // --- validation (underlying `ZodError` on `error.cause`) ---\n /** A board failed schema validation. `boardId` is set when known. */\n | { code: \"invalid-board\"; boardId?: string; issues: readonly OBFIssue[] }\n /** A manifest failed schema validation. */\n | { code: \"invalid-manifest\"; issues: readonly OBFIssue[] }\n // --- archive structure (reading an .obz) ---\n /** The archive has no `manifest.json`. */\n | { code: \"missing-manifest\" }\n /** A board the manifest declares is absent from the archive. */\n | { code: \"missing-board\"; boardId: string; path: string }\n /** A board's `id` disagrees with its manifest key. */\n | {\n code: \"board-id-mismatch\";\n path: string;\n declaredId: string;\n actualId: string;\n }\n // --- archive assembly (createOBZ) ---\n /** `rootBoardId` matches none of the supplied boards. */\n | { code: \"unknown-root\"; rootBoardId: string }\n /** Two supplied boards share the same `id`. */\n | { code: \"duplicate-board\"; boardId: string }\n /** A board declares a media `path` with no matching resource. */\n | {\n code: \"missing-resource\";\n kind: \"image\" | \"sound\";\n mediaId: string;\n path: string;\n }\n /** Two boards declare the same media ID with different paths. */\n | {\n code: \"conflicting-paths\";\n kind: \"image\" | \"sound\";\n mediaId: string;\n paths: [string, string];\n }\n /** A supplied resource would overwrite a generated board or the manifest. */\n | { code: \"path-collision\"; path: string }\n /** The archive could not be compressed. */\n | { code: \"zip-failed\" }\n /** An internal invariant was violated — a bug in this library; please report. */\n | { code: \"internal\"; detail: string };\n\n/** Every `code` an {@link OBFError} can carry. */\nexport type OBFErrorCode = OBFErrorInfo[\"code\"];\n\n/**\n * Structured error for expected failures from `@shayc/open-board-format`.\n *\n * Branch on {@link OBFError.info} (a discriminated {@link OBFErrorInfo}) rather\n * than parsing {@link OBFError.message}. Any underlying error — a `JSON.parse`\n * failure, a `ZodError`, or an fflate error — is on the standard `error.cause`.\n */\nexport class OBFError extends Error {\n /** Structured, discriminated description of the failure. */\n readonly info: OBFErrorInfo;\n\n constructor(info: OBFErrorInfo, options?: { cause?: unknown }) {\n super(formatOBFError(info), options);\n this.name = \"OBFError\";\n this.info = info;\n }\n}\n\n/** Derive a human-readable message from an {@link OBFErrorInfo}. */\nfunction formatOBFError(info: OBFErrorInfo): string {\n switch (info.code) {\n case \"not-json\":\n return `Invalid ${info.source === \"manifest\" ? \"OBZ manifest\" : \"OBF\"}: not valid JSON`;\n case \"not-zip\":\n return \"Invalid OBZ: not a ZIP file\";\n case \"unreadable-zip\":\n return \"ZIP archive could not be read\";\n case \"archive-too-large\":\n return info.limit === \"maxEntries\"\n ? `Invalid OBZ: entry count reached ${info.entryCount} at \"${info.path}\", exceeding the ${info.maxEntries}-entry limit`\n : info.limit === \"maxEntrySize\"\n ? `Invalid OBZ: entry \"${info.path}\" declares ${info.declaredBytes} bytes uncompressed, exceeding the ${info.maxBytes}-byte per-entry limit`\n : `Invalid OBZ: declared uncompressed size reached ${info.declaredBytes} bytes at \"${info.path}\", exceeding the ${info.maxBytes}-byte total limit`;\n case \"invalid-board\": {\n const subject = info.boardId ? `board \"${info.boardId}\"` : \"board\";\n return `Invalid OBF ${subject}:\\n${prettifyIssues(info.issues)}`;\n }\n case \"invalid-manifest\":\n return `Invalid OBZ manifest:\\n${prettifyIssues(info.issues)}`;\n case \"missing-manifest\":\n return \"Invalid OBZ: missing manifest.json\";\n case \"missing-board\":\n return `Invalid OBZ: board \"${info.boardId}\" is declared in the manifest but missing at \"${info.path}\"`;\n case \"board-id-mismatch\":\n return `Invalid OBZ: board at \"${info.path}\" has id \"${info.actualId}\" but the manifest declares it as \"${info.declaredId}\"`;\n case \"unknown-root\":\n return `Invalid OBZ: rootBoardId \"${info.rootBoardId}\" does not match any supplied board`;\n case \"duplicate-board\":\n return `Invalid OBZ: duplicate board id \"${info.boardId}\" — board ids must be unique within a package`;\n case \"missing-resource\":\n return `Invalid OBZ: ${info.kind} \"${info.mediaId}\" references \"${info.path}\" but no matching resource was supplied`;\n case \"conflicting-paths\":\n return `Invalid OBZ: ${info.kind} id \"${info.mediaId}\" maps to conflicting paths \"${info.paths[0]}\" and \"${info.paths[1]}\"`;\n case \"path-collision\":\n return `Invalid OBZ: resource path \"${info.path}\" collides with a generated board or manifest entry`;\n case \"zip-failed\":\n return \"Failed to build ZIP archive\";\n case \"internal\":\n return `Internal error (please report): ${info.detail}`;\n /* v8 ignore start -- exhaustiveness guard: unreachable, enforced at compile time */\n default: {\n const _exhaustive: never = info;\n return _exhaustive;\n }\n /* v8 ignore stop */\n }\n}\n\n/** Render schema issues using Zod's pretty formatter. */\nfunction prettifyIssues(issues: readonly OBFIssue[]): string {\n return z.prettifyError(new z.ZodError([...issues]));\n}\n","/**\n * Parsing, validation, and serialization for single `.obf` board files.\n */\n\nimport { OBFError } from \"./errors\";\nimport type { OBFBoard } from \"./schema\";\nimport { OBFBoardSchema } from \"./schema\";\n\nconst UTF8_BOM = \"\\uFEFF\";\n\n/** Strip a leading UTF-8 BOM, which some editors silently prepend. */\nfunction stripBom(text: string): string {\n return text.startsWith(UTF8_BOM) ? text.slice(1) : text;\n}\n\n/**\n * Parse a JSON string into a validated OBF board.\n *\n * Strips an optional UTF-8 BOM prefix before parsing and throws a\n * descriptive error if the input is malformed or fails schema validation.\n *\n * @param json - The JSON string to parse.\n * @returns The validated board object.\n *\n * @throws {@link OBFError} with `info.code` `\"not-json\"` if the JSON is\n * malformed, or `\"invalid-board\"` if it fails schema validation.\n */\nexport function parseOBF(json: string): OBFBoard {\n const sanitized = stripBom(json);\n\n let rawBoard: unknown;\n\n try {\n rawBoard = JSON.parse(sanitized) as unknown;\n } catch (error) {\n throw new OBFError({ code: \"not-json\", source: \"board\" }, { cause: error });\n }\n\n return validateOBF(rawBoard);\n}\n\n/**\n * Read a `File` and parse its contents as a validated OBF board.\n *\n * For a `Blob`, `ArrayBuffer`, or typed-array input, use `loadBoard` instead.\n *\n * @param file - A `File` handle pointing to an `.obf` file.\n * @returns The validated board object.\n *\n * @throws {@link OBFError} with `info.code` `\"not-json\"` if the file content is\n * malformed, or `\"invalid-board\"` if it fails schema validation.\n */\nexport async function loadOBF(file: File): Promise<OBFBoard> {\n const json = await file.text();\n return parseOBF(json);\n}\n\n/**\n * Validate an unknown value against the OBF board schema.\n *\n * @param data - The value to validate.\n * @returns The validated board object.\n *\n * @throws {@link OBFError} with `info.code` `\"invalid-board\"` if the value fails\n * schema validation. `info.issues` holds the underlying Zod issues.\n */\nexport function validateOBF(data: unknown): OBFBoard {\n const result = OBFBoardSchema.safeParse(data);\n\n if (!result.success) {\n throw new OBFError(\n { code: \"invalid-board\", issues: result.error.issues },\n { cause: result.error },\n );\n }\n\n return result.data;\n}\n\n/**\n * Stringify an OBF board to a pretty-printed JSON string.\n *\n * @param board - The board to stringify.\n * @returns A JSON string with two-space indentation.\n */\nexport function stringifyOBF(board: OBFBoard): string {\n return JSON.stringify(board, null, 2);\n}\n","/**\n * Minimal ZIP helpers over fflate: signature sniffing, unzip, and zip.\n */\n\nimport type { UnzipFileInfo } from \"fflate\";\nimport { unzip as fflateUnzip, zip as fflateZip } from \"fflate\";\nimport { OBFError } from \"./errors\";\n\n/**\n * First two bytes of every ZIP archive — the ASCII letters `PK`,\n * after Phil Katz, creator of the format.\n *\n * Only the 2-byte prefix is checked intentionally: this keeps detection\n * lightweight and is sufficient for distinguishing ZIP from JSON.\n */\nconst ZIP_MAGIC = [0x50, 0x4b] as const;\n\n/** Balanced speed-vs-size deflate level, on fflate's 0–9 scale (0 = store). */\nconst COMPRESSION_LEVEL = 6;\n\n/**\n * Anything this package accepts as binary board data: a raw buffer, any\n * typed-array view into one (including Node's `Buffer`), or a `File`/`Blob`\n * handle.\n */\nexport type BinaryInput = File | Blob | ArrayBuffer | ArrayBufferView;\n\n/**\n * Normalize any {@link BinaryInput} shape into a plain `ArrayBuffer`.\n *\n * A view is sliced to its own window rather than returning `.buffer`\n * directly, since a `Uint8Array`/`Buffer` may cover only part of a larger,\n * possibly shared, underlying buffer.\n */\nexport async function toArrayBuffer(input: BinaryInput): Promise<ArrayBuffer> {\n if (input instanceof ArrayBuffer) {\n return input;\n }\n\n if (ArrayBuffer.isView(input)) {\n return input.buffer.slice(\n input.byteOffset,\n input.byteOffset + input.byteLength,\n ) as ArrayBuffer;\n }\n\n return input.arrayBuffer();\n}\n\n/**\n * Optional caps on declared uncompressed sizes, checked against ZIP metadata\n * before each entry is inflated. These limits reduce allocation risk but are\n * not strict memory guarantees because archive metadata can be dishonest.\n */\nexport interface UnzipLimits {\n /** Max declared uncompressed size of any single entry, in bytes. */\n maxEntrySize?: number;\n /** Max sum of declared uncompressed sizes across all entries, in bytes. */\n maxTotalOriginalSize?: number;\n /** Max number of entries, counting directory entries the archive declares. */\n maxEntries?: number;\n}\n\n/** Options for {@link unzip} and the OBZ loaders that delegate to it. */\nexport interface UnzipOptions {\n /** Optional {@link UnzipLimits} enforced during extraction. */\n limits?: UnzipLimits;\n}\n\n/**\n * Decompress a ZIP archive into a map of file paths to raw bytes.\n *\n * Directory entries (paths ending in `/`, which some tools write explicitly\n * even though ZIP doesn't require them) are dropped — they carry no content\n * and this map is documented as file paths to bytes.\n *\n * @param archive - The ZIP archive as an `ArrayBuffer`.\n * @param options - Optional {@link UnzipOptions}. `options.limits` is checked\n * per entry against declared (metadata) sizes before that entry is\n * inflated. No limits are applied by default.\n * @returns A map of file paths to their decompressed content.\n *\n * @throws {@link OBFError} with `info.code` `\"unreadable-zip\"` if the archive is\n * corrupt or cannot be decompressed, or `\"archive-too-large\"` if a limit in\n * `options.limits` is exceeded.\n * @throws {@link TypeError} if a limit in `options.limits` is `NaN`.\n */\nexport function unzip(\n archive: ArrayBuffer,\n options?: UnzipOptions,\n): Promise<Map<string, Uint8Array>> {\n for (const key of [\n \"maxEntrySize\",\n \"maxTotalOriginalSize\",\n \"maxEntries\",\n ] as const) {\n const value = options?.limits?.[key];\n if (value !== undefined && Number.isNaN(value)) {\n throw new TypeError(`limits.${key} must not be NaN`);\n }\n }\n\n return new Promise((resolve, reject) => {\n const compressed = new Uint8Array(archive);\n const { maxEntrySize, maxTotalOriginalSize, maxEntries } =\n options?.limits ?? {};\n\n let limitError: OBFError | undefined;\n let settled = false;\n let totalDeclared = 0;\n let entryCount = 0;\n\n const filter = (file: UnzipFileInfo): boolean => {\n if (limitError) {\n return false; // Skip the remaining entries cheaply.\n }\n\n entryCount += 1;\n if (maxEntries !== undefined && entryCount > maxEntries) {\n limitError = new OBFError({\n code: \"archive-too-large\",\n limit: \"maxEntries\",\n maxEntries,\n entryCount,\n path: file.name,\n });\n return false;\n }\n\n if (maxEntrySize !== undefined && file.originalSize > maxEntrySize) {\n limitError = new OBFError({\n code: \"archive-too-large\",\n limit: \"maxEntrySize\",\n maxBytes: maxEntrySize,\n declaredBytes: file.originalSize,\n path: file.name,\n });\n return false;\n }\n\n totalDeclared += file.originalSize;\n if (\n maxTotalOriginalSize !== undefined &&\n totalDeclared > maxTotalOriginalSize\n ) {\n limitError = new OBFError({\n code: \"archive-too-large\",\n limit: \"maxTotalOriginalSize\",\n maxBytes: maxTotalOriginalSize,\n declaredBytes: totalDeclared,\n path: file.name,\n });\n return false;\n }\n\n return true;\n };\n\n const terminate = fflateUnzip(\n compressed,\n options?.limits ? { filter } : {},\n (error, entries) => {\n if (settled) {\n return;\n }\n settled = true;\n\n if (error) {\n reject(new OBFError({ code: \"unreadable-zip\" }, { cause: error }));\n return;\n }\n\n if (limitError) {\n reject(limitError);\n return;\n }\n\n const pathToBytes = new Map(\n Object.entries(entries).filter(([path]) => !path.endsWith(\"/\")),\n );\n\n resolve(pathToBytes);\n },\n );\n\n if (limitError) {\n const error = limitError;\n // This deliberately lets archive-too-large pre-empt an in-flight entry's\n // corruption error.\n terminate(); // Stop any dispatched asynchronous inflate workers.\n // Defer so an archive error from fflate's synchronous pass settles first.\n queueMicrotask(() => {\n if (!settled) {\n settled = true;\n reject(error);\n }\n });\n }\n });\n}\n\n/**\n * Compress a map of file paths and contents into a single ZIP archive.\n *\n * Accepts both `Uint8Array` and `ArrayBuffer` values so callers can\n * pass the output of {@link unzip} directly or supply raw `ArrayBuffer`s\n * without converting first.\n *\n * @param entries - A map of file paths to their content bytes.\n * @returns The compressed archive as a `Uint8Array`.\n *\n * @throws {@link OBFError} with `info.code` `\"zip-failed\"` if fflate fails to\n * compress an entry.\n */\nexport function zip(\n entries: Map<string, Uint8Array | ArrayBuffer>,\n): Promise<Uint8Array> {\n return new Promise((resolve, reject) => {\n const pathToBytes: Record<string, Uint8Array> = {};\n\n for (const [path, content] of entries) {\n pathToBytes[path] =\n content instanceof Uint8Array ? content : new Uint8Array(content);\n }\n\n fflateZip(pathToBytes, { level: COMPRESSION_LEVEL }, (error, result) => {\n /* v8 ignore start -- defensive: fflate does not error on valid byte input */\n if (error) {\n reject(new OBFError({ code: \"zip-failed\" }, { cause: error }));\n return;\n }\n /* v8 ignore stop */\n\n resolve(result);\n });\n });\n}\n\n/**\n * Test whether an `ArrayBuffer` begins with the two-byte ZIP magic\n * prefix (`PK`).\n *\n * @param archive - The buffer to inspect.\n * @returns `true` if the buffer starts with the ZIP signature.\n */\nexport function isZip(archive: ArrayBuffer): boolean {\n const bytes = new Uint8Array(archive);\n\n return (\n bytes.length >= ZIP_MAGIC.length &&\n ZIP_MAGIC.every((byte, index) => bytes[index] === byte)\n );\n}\n","/**\n * Creation and extraction of `.obz` board packages.\n */\n\nimport { OBFError } from \"./errors\";\nimport { parseOBF } from \"./obf\";\nimport type { OBFBoard, OBFManifest } from \"./schema\";\nimport { OBFBoardSchema, OBFManifestSchema } from \"./schema\";\nimport type { BinaryInput, UnzipOptions } from \"./zip\";\nimport { isZip, toArrayBuffer, unzip, zip } from \"./zip\";\n\n/**\n * Fully extracted contents of an `.obz` archive.\n */\nexport interface ParsedOBZ {\n /** The package's table of contents. */\n manifest: OBFManifest;\n /** Validated board objects keyed by board ID. */\n boards: Map<string, OBFBoard>;\n /**\n * The package's entry-point board — the one `manifest.root` points at,\n * already resolved. Same object as `boards.get(rootBoard.id)`.\n */\n rootBoard: OBFBoard;\n /**\n * Raw bytes for every entry in the archive, keyed by archive path —\n * including `manifest.json` and the `.obf` boards as well as media\n * such as images and sounds.\n */\n resources: Map<string, Uint8Array>;\n}\n\n/**\n * Read a `File` and extract its contents as a parsed OBZ package.\n *\n * A thin convenience wrapper — {@link extractOBZ} accepts a `File` directly,\n * so this exists only for the naming symmetry with {@link loadOBF}.\n *\n * @param file - A `File` handle pointing to an `.obz` archive.\n * @param options - Optional {@link UnzipOptions} on declared uncompressed sizes.\n * @returns The parsed manifest, boards, root board, and binary resources.\n *\n * @throws {@link OBFError} — the same failures as {@link extractOBZ}, which\n * this delegates to.\n * @throws {@link TypeError} if a limit in `options.limits` is `NaN`.\n */\nexport async function loadOBZ(\n file: File,\n options?: UnzipOptions,\n): Promise<ParsedOBZ> {\n return extractOBZ(file, options);\n}\n\n/**\n * Decompress an OBZ archive and return its manifest, boards, and resources.\n *\n * @param archive - The OBZ archive as a `File`, `Blob`, `ArrayBuffer`, or\n * `ArrayBufferView` (e.g. a Node `Buffer`).\n * @param options - Optional {@link UnzipOptions}. `options.limits` caps\n * declared uncompressed sizes, checked before inflation. No limits are\n * applied by default.\n * @returns A {@link ParsedOBZ} with the archive's manifest, boards, root\n * board, and resources.\n *\n * @throws {@link OBFError}; branch on `info.code`: `\"not-zip\"`,\n * `\"unreadable-zip\"`, `\"archive-too-large\"` (a limit in `options.limits` is\n * exceeded), `\"missing-manifest\"`, `\"not-json\"` or `\"invalid-manifest\"`\n * (bad manifest), `\"missing-board\"`, `\"board-id-mismatch\"`, or\n * `\"invalid-board\"` (a board fails validation).\n * @throws {@link TypeError} if a limit in `options.limits` is `NaN`.\n */\nexport async function extractOBZ(\n archive: BinaryInput,\n options?: UnzipOptions,\n): Promise<ParsedOBZ> {\n const buffer = await toArrayBuffer(archive);\n\n if (!isZip(buffer)) {\n throw new OBFError({ code: \"not-zip\" });\n }\n\n const entries = await unzip(buffer, options);\n\n const manifest = extractManifest(entries);\n const { boards, rootBoard } = extractBoards(manifest, entries);\n\n return { manifest, boards, rootBoard, resources: entries };\n}\n\n/**\n * Parse and validate an OBZ manifest — the table of contents that maps\n * board IDs to their file paths within the archive.\n *\n * @param json - A JSON string representing the manifest.\n * @returns The validated manifest object.\n *\n * @throws {@link OBFError} with `info.code` `\"not-json\"` if the JSON is\n * malformed, or `\"invalid-manifest\"` if it fails schema validation.\n */\nexport function parseManifest(json: string): OBFManifest {\n let data: unknown;\n\n try {\n data = JSON.parse(json) as unknown;\n } catch (error) {\n throw new OBFError(\n { code: \"not-json\", source: \"manifest\" },\n { cause: error },\n );\n }\n\n const result = OBFManifestSchema.safeParse(data);\n\n if (!result.success) {\n throw new OBFError(\n { code: \"invalid-manifest\", issues: result.error.issues },\n { cause: result.error },\n );\n }\n\n return result.data;\n}\n\n/**\n * Bundle boards and optional resources into a compressed OBZ archive.\n *\n * A manifest is generated automatically from the supplied boards,\n * using the `rootBoardId` to designate the entry-point board.\n *\n * Every failure is an {@link OBFError}; branch on `info.code`.\n *\n * @param boards - The boards to include in the archive.\n * @param rootBoardId - The entry-point board's ID.\n * @param resources - Optional map of archive paths to binary content.\n * @returns A `Blob` containing the compressed OBZ archive.\n *\n * @throws {@link OBFError} `\"unknown-root\"` if `rootBoardId` does not match any\n * supplied board.\n * @throws {@link OBFError} `\"duplicate-board\"` if two supplied boards share\n * the same ID.\n * @throws {@link OBFError} `\"invalid-board\"` if a supplied board fails schema\n * validation.\n * @throws {@link OBFError} `\"conflicting-paths\"` if two boards map the same\n * media ID to different paths.\n * @throws {@link OBFError} `\"missing-resource\"` if a board declares an image\n * or sound `path` with no matching entry in `resources`.\n * @throws {@link OBFError} `\"path-collision\"` if a resource would overwrite\n * the generated manifest or a board file.\n * @throws {@link OBFError} `\"zip-failed\"` if archive compression fails.\n */\nexport async function createOBZ(\n boards: OBFBoard[],\n rootBoardId: string,\n resources?: Map<string, Uint8Array | ArrayBuffer>,\n): Promise<Blob> {\n if (!boards.some((board) => board.id === rootBoardId)) {\n throw new OBFError({ code: \"unknown-root\", rootBoardId });\n }\n\n const seenBoardIds = new Set<string>();\n for (const board of boards) {\n if (seenBoardIds.has(board.id)) {\n throw new OBFError({ code: \"duplicate-board\", boardId: board.id });\n }\n seenBoardIds.add(board.id);\n }\n\n const entries = new Map<string, Uint8Array | ArrayBuffer>();\n\n const boardPaths = Object.fromEntries(\n boards.map((board) => [board.id, boardPath(board.id)]),\n );\n\n const imagePaths = collectMediaPaths(boards, \"images\");\n const soundPaths = collectMediaPaths(boards, \"sounds\");\n\n const manifestResult = OBFManifestSchema.safeParse({\n format: \"open-board-0.1\",\n root: boardPath(rootBoardId),\n paths: {\n boards: boardPaths,\n images: imagePaths,\n sounds: soundPaths,\n },\n });\n\n /* v8 ignore start -- defensive: the manifest is built from already-validated inputs */\n if (!manifestResult.success) {\n throw new OBFError(\n { code: \"internal\", detail: \"generated manifest failed validation\" },\n { cause: manifestResult.error },\n );\n }\n /* v8 ignore stop */\n\n const manifest = manifestResult.data;\n\n const encoder = new TextEncoder();\n\n entries.set(\n \"manifest.json\",\n encoder.encode(JSON.stringify(manifest, null, 2)),\n );\n\n for (const board of boards) {\n const result = OBFBoardSchema.safeParse(board);\n if (!result.success) {\n throw new OBFError(\n {\n code: \"invalid-board\",\n boardId: board.id,\n issues: result.error.issues,\n },\n { cause: result.error },\n );\n }\n\n entries.set(\n boardPaths[board.id]!,\n encoder.encode(JSON.stringify(result.data, null, 2)),\n );\n }\n\n if (resources) {\n for (const [path, bytes] of resources) {\n if (entries.has(path)) {\n throw new OBFError({ code: \"path-collision\", path });\n }\n entries.set(path, bytes);\n }\n }\n\n assertPathsPresent(\"image\", imagePaths, entries);\n assertPathsPresent(\"sound\", soundPaths, entries);\n\n const compressed = await zip(entries);\n return new Blob([compressed], { type: \"application/zip\" });\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Derive a board's archive path from its ID.\n *\n * Board IDs may be any non-empty string, but archive paths give `/` and `\\`\n * structural meaning. Percent-encoding keeps the mapping deterministic and\n * collision-free without rejecting any valid ID: `/` or `..` is encoded as\n * filename text, never interpreted as a path segment.\n */\nfunction boardPath(id: string): string {\n return `boards/${encodeURIComponent(id)}.obf`;\n}\n\n/**\n * Walk every board's media collection and produce the ID-to-path map the spec\n * calls \"redundant but still required\" for the OBZ manifest.\n *\n * Throws when two boards map the same media ID to different paths. Without\n * this check, the generated manifest could silently point to the wrong file.\n */\nfunction collectMediaPaths(\n boards: OBFBoard[],\n kind: \"images\" | \"sounds\",\n): Record<string, string> {\n const paths: Record<string, string> = {};\n\n for (const board of boards) {\n for (const media of board[kind] ?? []) {\n if (media.path === undefined) {\n continue;\n }\n\n const existing = paths[media.id];\n if (existing !== undefined && existing !== media.path) {\n throw new OBFError({\n code: \"conflicting-paths\",\n kind: kind === \"images\" ? \"image\" : \"sound\",\n mediaId: media.id,\n paths: [existing, media.path],\n });\n }\n paths[media.id] = media.path;\n }\n }\n\n return paths;\n}\n\n/**\n * Assert that every media path the generated manifest declares exists as an\n * archive entry — the same contract {@link extractOBZ} assumes when reading.\n *\n * Only media with a `path` reach this check, so `url`/`data`-only media are\n * never flagged.\n */\nfunction assertPathsPresent(\n kind: \"image\" | \"sound\",\n paths: Record<string, string>,\n entries: Map<string, Uint8Array | ArrayBuffer>,\n): void {\n for (const [id, path] of Object.entries(paths)) {\n if (!entries.has(path)) {\n throw new OBFError({\n code: \"missing-resource\",\n kind,\n mediaId: id,\n path,\n });\n }\n }\n}\n\nfunction extractManifest(entries: Map<string, Uint8Array>): OBFManifest {\n const manifestBytes = entries.get(\"manifest.json\");\n\n if (!manifestBytes) {\n throw new OBFError({ code: \"missing-manifest\" });\n }\n\n const manifestJson = new TextDecoder().decode(manifestBytes);\n return parseManifest(manifestJson);\n}\n\nfunction extractBoards(\n manifest: OBFManifest,\n entries: Map<string, Uint8Array>,\n): { boards: Map<string, OBFBoard>; rootBoard: OBFBoard } {\n const boards = new Map<string, OBFBoard>();\n let rootBoard: OBFBoard | undefined;\n\n for (const [id, path] of Object.entries(manifest.paths.boards)) {\n const boardBytes = entries.get(path);\n\n if (!boardBytes) {\n throw new OBFError({ code: \"missing-board\", boardId: id, path });\n }\n\n const boardJson = new TextDecoder().decode(boardBytes);\n const board = parseOBF(boardJson);\n\n if (board.id !== id) {\n throw new OBFError({\n code: \"board-id-mismatch\",\n path,\n declaredId: id,\n actualId: board.id,\n });\n }\n\n boards.set(id, board);\n\n if (path === manifest.root) {\n rootBoard = board;\n }\n }\n\n // `OBFManifestSchema` requires `root` to be one of `paths.boards`, so the loop\n // above always assigns `rootBoard` for the validated manifests we receive.\n /* v8 ignore start -- defensive: OBFManifestSchema guarantees root ∈ paths.boards */\n if (!rootBoard) {\n throw new OBFError({\n code: \"internal\",\n detail: `root board \"${manifest.root}\" not found in paths.boards`,\n });\n }\n /* v8 ignore stop */\n\n return { boards, rootBoard };\n}\n","/**\n * Format-agnostic loading of `.obf` boards and `.obz` packages.\n */\n\nimport { parseOBF } from \"./obf\";\nimport type { ParsedOBZ } from \"./obz\";\nimport { extractOBZ } from \"./obz\";\nimport type { OBFBoard } from \"./schema\";\nimport type { BinaryInput, UnzipOptions } from \"./zip\";\nimport { isZip, toArrayBuffer } from \"./zip\";\n\n/**\n * Result of {@link loadBoard} — a discriminated union over the two file\n * shapes the Open Board Format defines.\n *\n * Switch on `format` to narrow:\n *\n * ```ts\n * const loaded = await loadBoard(file);\n * if (loaded.format === \"obz\") {\n * loaded.archive.rootBoard; // entry-point board of the ParsedOBZ archive\n * } else {\n * loaded.board; // OBFBoard\n * }\n * ```\n */\nexport type LoadedBoard =\n | { format: \"obz\"; archive: ParsedOBZ }\n | { format: \"obf\"; board: OBFBoard };\n\n/**\n * Detect whether the input is a single OBF board or an OBZ package and load it\n * accordingly.\n *\n * Input that begins with the ZIP magic prefix is treated as an `.obz` package;\n * anything else is parsed as an `.obf` board. The input is read once, so\n * consumers can accept either format from a single drag-and-drop, file picker,\n * or fetch response without inspecting the file extension or re-deriving the\n * OBF-vs-OBZ distinction themselves.\n *\n * @param input - A `File`, `Blob`, `ArrayBuffer`, or `ArrayBufferView`\n * (e.g. a Node `Buffer`) holding `.obf` or `.obz` content.\n * @param options - Optional {@link UnzipOptions}. Applies only when the input\n * is an OBZ archive; ignored for `.obf` JSON.\n * @returns A discriminated union tagged by `format`.\n *\n * @throws {@link OBFError} — the OBZ failures of {@link extractOBZ} when the\n * input is an archive, or the OBF failures of {@link parseOBF} otherwise.\n * Branch on `error.info.code`.\n * @throws {@link TypeError} if a limit in `options.limits` is `NaN` and the\n * input is an OBZ archive.\n */\nexport async function loadBoard(\n input: BinaryInput,\n options?: UnzipOptions,\n): Promise<LoadedBoard> {\n const buffer = await toArrayBuffer(input);\n\n if (isZip(buffer)) {\n return { format: \"obz\", archive: await extractOBZ(buffer, options) };\n }\n\n return { format: \"obf\", board: parseOBF(new TextDecoder().decode(buffer)) };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shayc/open-board-format",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "description": "Parse, validate, and create Open Board Format (.obf/.obz) files — the open standard for Augmentative and Alternative Communication (AAC) boards. TypeScript, browser and Node.js.",
5
5
  "license": "MIT",
6
6
  "author": "Shay Cojocaru <shayc@outlook.com>",
@@ -13,17 +13,10 @@
13
13
  "url": "https://github.com/shayc/open-board-format/issues"
14
14
  },
15
15
  "keywords": [
16
- "aac",
17
- "aac-board",
18
16
  "assistive-technology",
19
17
  "augmentative-alternative-communication",
20
18
  "communication-board",
21
- "obf",
22
- "obz",
23
- "open-board-format",
24
- "parser",
25
- "validator",
26
- "zod"
19
+ "open-board-format"
27
20
  ],
28
21
  "type": "module",
29
22
  "sideEffects": false,
@@ -42,9 +35,9 @@
42
35
  "scripts": {
43
36
  "build": "tsdown",
44
37
  "dev": "tsdown --watch",
45
- "lint": "eslint .",
46
- "format": "prettier --write .",
47
- "format:check": "prettier --check .",
38
+ "lint": "oxlint",
39
+ "format": "oxfmt",
40
+ "format:check": "oxfmt --check",
48
41
  "test": "vitest run",
49
42
  "test:coverage": "vitest run --coverage",
50
43
  "typecheck": "tsc",
@@ -60,23 +53,20 @@
60
53
  "fflate": "^0.8.3"
61
54
  },
62
55
  "peerDependencies": {
63
- "zod": "^4.4.3"
56
+ "zod": "^4.0.0"
64
57
  },
65
58
  "devDependencies": {
66
- "@changesets/cli": "^3.0.0",
67
- "@eslint/js": "^10.0.1",
59
+ "@changesets/cli": "^3.0.1",
68
60
  "@types/node": "^22.0.0",
69
- "@vitest/coverage-v8": "^4.1.10",
70
- "eslint": "^10.8.1",
71
- "eslint-config-prettier": "^10.1.8",
72
- "globals": "^17.11.0",
73
- "prettier": "^3.9.6",
74
- "publint": "^0.3.23",
61
+ "@vitest/coverage-v8": "^4.1.11",
62
+ "oxfmt": "0.65.0",
63
+ "oxlint": "1.80.0",
64
+ "oxlint-tsgolint": "7.0.2001",
65
+ "publint": "^0.3.24",
75
66
  "tsdown": "^0.22.14",
76
- "typescript": "~6.0.3",
77
- "typescript-eslint": "^8.67.0",
78
- "vitest": "^4.1.10",
79
- "zod": "^4.4.3"
67
+ "typescript": "~7.0.2",
68
+ "vitest": "^4.1.11",
69
+ "zod": "^4.5.4"
80
70
  },
81
71
  "publishConfig": {
82
72
  "access": "public"