@shayc/open-board-format 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @shayc/open-board-format
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bdf9fc5: Add `rootBoard` to `ParsedOBZ`: `extractOBZ` and `loadOBZ` now resolve the package's entry-point board (the one `manifest.root` points at) and return it directly, so consumers no longer need to invert `manifest.paths.boards` to find the home board.
8
+
9
+ Extraction is also stricter: each board's `id` must match its key in `manifest.paths.boards`. Archives where they disagree now throw `Invalid OBZ: board at "<path>" has id "<id>" but the manifest declares it as "<key>"`. Such archives were previously accepted, but their boards were unreachable by `load_board.id` navigation, so this turns a silent inconsistency into a clear error.
10
+
3
11
  ## 0.3.0
4
12
 
5
13
  ### Minor Changes
package/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # `@shayc/open-board-format`
2
2
 
3
- A TypeScript toolkit for [Open Board Format](https://www.openboardformat.org/) — the open standard for Augmentative and Alternative Communication (AAC) boards. Parse, validate, and create OBF boards and OBZ packages, all backed by [Zod](https://zod.dev/) schemas with full TypeScript types inferred.
4
-
5
3
  [![npm version](https://img.shields.io/npm/v/@shayc/open-board-format)](https://www.npmjs.com/package/@shayc/open-board-format)
6
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)
7
5
  [![License: MIT](https://img.shields.io/npm/l/@shayc/open-board-format.svg)](LICENSE)
8
6
 
9
- OBF (`.obf`) is a JSON file describing a single communication board buttons, images, sounds, grid layout, metadata. OBZ (`.obz`) is a ZIP archive bundling one or more `.obf` boards with their media and a `manifest.json`.
7
+ A TypeScript toolkit for [Open Board Format](https://www.openboardformat.org/) — the open standard for Augmentative and Alternative Communication (AAC) boards. OBF (`.obf`) is a JSON file describing a single communication board: buttons, images, sounds, grid layout, metadata. OBZ (`.obz`) is a ZIP archive bundling one or more boards with their media and a `manifest.json`. This package parses, validates, and creates both.
8
+
9
+ - **Typed end to end** — every type is inferred from a [Zod](https://zod.dev/) schema, and every schema is exported for `safeParse` or composing into your own contracts.
10
+ - **Browser and Node.js 22+** — pure ESM, works against `File` and `ArrayBuffer`.
11
+ - **One entry point for either format** — `loadBoard` sniffs the bytes and tells you whether it found an `.obf` board or an `.obz` package.
12
+ - **Spec-faithful round trips** — unknown fields are preserved rather than stripped, so vendor extensions allowed by the OBF spec survive `parseOBF` → `stringifyOBF`.
13
+ - **Small footprint** — two runtime dependencies (Zod and [fflate](https://github.com/101arrowz/fflate)), tree-shakeable, no side effects.
10
14
 
11
15
  ## Install
12
16
 
@@ -14,87 +18,78 @@ OBF (`.obf`) is a JSON file describing a single communication board — buttons,
14
18
  npm install @shayc/open-board-format
15
19
  ```
16
20
 
21
+ ESM only — CommonJS (`require`) is not supported.
22
+
17
23
  ## Quick start
18
24
 
19
25
  ```ts
20
- import { parseOBF } from "@shayc/open-board-format";
21
-
22
- const board = parseOBF(jsonString);
23
- console.log(board.id, board.buttons.length);
24
- ```
25
-
26
- `parseOBF` throws on invalid input; the returned value is a fully typed `OBFBoard`. For other input shapes (already-parsed object, browser `File`, OBZ archive), see [Overview](#overview).
27
-
28
- ## Overview
29
-
30
- Two file types; pick the entry point by what you have:
31
-
32
- - **OBF** is a single board (a JSON object). Use `parseOBF` for a JSON string, `validateOBF` for an already-parsed object, `loadOBF` for a browser `File`. `stringifyOBF` serializes back out.
33
- - **OBZ** is a package of boards plus media (a ZIP archive). Use `loadOBZ` for a `File`, `extractOBZ` for an `ArrayBuffer`, `createOBZ` to build a new one.
26
+ import { loadBoard } from "@shayc/open-board-format";
34
27
 
35
- If you accept a `File` (or raw `ArrayBuffer`) and don't know which of the two it is, use `loadBoard` it sniffs the bytes and returns a `{ format, ... }` union so you don't have to inspect the extension yourself.
28
+ // `file` came from drag-and-drop or <input type="file">could be .obf or .obz
29
+ const loaded = await loadBoard(file);
36
30
 
37
- Every OBF type ships with a matching `*Schema` Zod schema (e.g. `OBFBoardSchema`, `OBFManifestSchema`), so you can validate inline with `safeParse` or wire the schema straight into an API contract — the TypeScript types are inferred from those schemas.
31
+ if (loaded.format === "obf") {
32
+ console.log(loaded.board.buttons.length);
33
+ } else {
34
+ console.log(loaded.archive.rootBoard.buttons.length);
35
+ }
36
+ ```
38
37
 
39
- Validation preserves unknown fields rather than stripping them, so vendor extensions allowed by the OBF spec survive a `parseOBF` `stringifyOBF` round trip.
38
+ `loadBoard` accepts a `File` or `ArrayBuffer` and throws on invalid input (see [Errors](#errors)). Already holding a JSON string? `parseOBF(json)` returns a validated `OBFBoard` directly.
40
39
 
41
- ## Requirements
40
+ ## Usage
42
41
 
43
- - **Module format:** ESM only.
44
- - **Runtime:** browser or Node 22+ — works against `File`, `ArrayBuffer`, and `Blob`.
42
+ ### Which function do I need?
45
43
 
46
- ## Examples
44
+ - **You have a single board (OBF).** Use `parseOBF` for a JSON string, `validateOBF` for an already-parsed object, `loadOBF` for a browser `File`. `stringifyOBF` serializes back out.
45
+ - **You have a package of boards plus media (OBZ).** Use `loadOBZ` for a `File`, `extractOBZ` for an `ArrayBuffer`, `createOBZ` to build a new one.
46
+ - **You don't know which you have.** Use `loadBoard` — it sniffs the bytes and returns a `{ format, ... }` union so you don't have to inspect the file extension yourself.
47
47
 
48
- ### Extract an OBZ package
48
+ ### Read an OBZ package
49
49
 
50
50
  ```ts
51
51
  import { loadOBZ, extractOBZ } from "@shayc/open-board-format";
52
52
 
53
53
  // From a File (e.g. drag-and-drop)
54
- const { manifest, boards, resources } = await loadOBZ(file);
54
+ const { rootBoard, boards, resources } = await loadOBZ(file);
55
55
 
56
56
  // Or from an ArrayBuffer (e.g. fetch response)
57
57
  const parsed = await extractOBZ(buffer);
58
+ ```
59
+
60
+ `rootBoard` is the package's home board — the one `manifest.root` points at, already resolved. `boards` is keyed by board ID and `resources` by archive path; the `manifest` is also returned if you need the raw table of contents.
61
+
62
+ Resources are raw bytes. To display an image in the browser:
58
63
 
59
- const homeBoard = parsed.boards.get("1");
60
- const imageBytes = parsed.resources.get("images/logo.png");
64
+ ```ts
65
+ const bytes = resources.get("images/hello.png")!;
66
+ const url = URL.createObjectURL(new Blob([bytes]));
61
67
  ```
62
68
 
63
69
  ### Create an OBZ package
64
70
 
71
+ Buttons reference media by ID (`image_id`, `sound_id`); the board's `images`/`sounds` entries carry the archive `path`; the resources map supplies the bytes for each path. Every `path` a board declares must have a matching resource entry, or `createOBZ` throws.
72
+
65
73
  ```ts
66
74
  import { createOBZ } from "@shayc/open-board-format";
67
75
  import type { OBFBoard } from "@shayc/open-board-format";
68
76
 
69
- const boards: OBFBoard[] = [
70
- {
71
- format: "open-board-0.1",
72
- id: "board-1",
73
- buttons: [{ id: "btn-1", label: "Hello" }],
74
- grid: { rows: 1, columns: 1, order: [["btn-1"]] },
75
- },
76
- ];
77
+ const board: OBFBoard = {
78
+ format: "open-board-0.1",
79
+ id: "board-1",
80
+ buttons: [{ id: "btn-1", label: "Hello", image_id: "img-1" }],
81
+ grid: { rows: 1, columns: 1, order: [["btn-1"]] },
82
+ images: [{ id: "img-1", path: "images/hello.png" }],
83
+ };
77
84
 
78
85
  const pngBytes = new Uint8Array(/* ... */);
79
- const resources = new Map([["images/logo.png", pngBytes]]);
80
- const blob = await createOBZ(boards, "board-1", resources);
81
- ```
82
-
83
- ### Load either format from one input
84
-
85
- ```ts
86
- import { loadBoard } from "@shayc/open-board-format";
87
-
88
- // `file` came from a drag-and-drop or <input type="file"> — could be .obf or .obz
89
- const loaded = await loadBoard(file);
86
+ const resources = new Map([["images/hello.png", pngBytes]]);
90
87
 
91
- if (loaded.format === "obz") {
92
- const homeBoard = loaded.archive.boards.get("1");
93
- } else {
94
- const board = loaded.board;
95
- }
88
+ const blob = await createOBZ([board], "board-1", resources);
96
89
  ```
97
90
 
91
+ The `manifest.json` is generated for you — boards are written to `boards/<id>.obf`, and `rootBoardId` (the second argument) selects the home board.
92
+
98
93
  ### Validate with Zod directly
99
94
 
100
95
  ```ts
@@ -111,6 +106,8 @@ if (result.success) {
111
106
 
112
107
  ## API
113
108
 
109
+ One naming convention covers the whole surface: `parse*` takes a JSON string, `validate*` takes an already-parsed object, `load*` takes a browser `File` (`loadBoard` also accepts an `ArrayBuffer`), `stringify*` returns a JSON string — and `extractOBZ`/`createOBZ` operate on whole archives.
110
+
114
111
  ### OBF (single board)
115
112
 
116
113
  | Function | Description |
@@ -122,14 +119,14 @@ if (result.success) {
122
119
 
123
120
  ### OBZ (board package)
124
121
 
125
- | Function | Description |
126
- | -------------------------------------------- | ------------------------------------------------------------- |
127
- | `loadOBZ(file)` | Load an OBZ package from a browser `File` |
128
- | `extractOBZ(archive)` | Extract boards, manifest, and resources from an `ArrayBuffer` |
129
- | `createOBZ(boards, rootBoardId, resources?)` | Create an OBZ package as a `Blob` |
130
- | `parseManifest(json)` | Parse a `manifest.json` string into a validated `OBFManifest` |
122
+ | Function | Description |
123
+ | -------------------------------------------- | ------------------------------------------------------------------------- |
124
+ | `loadOBZ(file)` | Load an OBZ package from a browser `File` |
125
+ | `extractOBZ(archive)` | Extract boards, manifest, root board, and resources from an `ArrayBuffer` |
126
+ | `createOBZ(boards, rootBoardId, resources?)` | Create an OBZ package as a `Blob` |
127
+ | `parseManifest(json)` | Parse a `manifest.json` string into a validated `OBFManifest` |
131
128
 
132
- ### Either format
129
+ ### Format detection
133
130
 
134
131
  | Function | Description |
135
132
  | ------------------ | ------------------------------------------------------------------------------------------- |
@@ -145,28 +142,28 @@ if (result.success) {
145
142
 
146
143
  ### Types
147
144
 
148
- | Type | Description |
149
- | --------------------- | ------------------------------------------------------------------------------------- |
150
- | `OBFBoard` | A single communication board |
151
- | `OBFGrid` | Grid layout (rows, columns, order) |
152
- | `OBFButton` | A button on the board |
153
- | `OBFButtonAction` | Button action (spelling or specialty) |
154
- | `OBFSpellingAction` | Spelling action (e.g., `+s`) |
155
- | `OBFSpecialtyAction` | Specialty action (e.g., `:clear`) |
156
- | `OBFLoadBoard` | Reference to load another board |
157
- | `OBFMedia` | Common media properties (base for `OBFImage` and `OBFSound`) |
158
- | `OBFImage` | An image resource (extends `OBFMedia`) |
159
- | `OBFSound` | A sound resource (extends `OBFMedia`) |
160
- | `OBFSymbolInfo` | Symbol set reference |
161
- | `OBFManifest` | OBZ package manifest |
162
- | `ParsedOBZ` | Return type of `extractOBZ` / `loadOBZ` — `{ manifest, boards, resources }` |
163
- | `LoadedBoard` | Return type of `loadBoard` — `{ format: "obz", archive } \| { format: "obf", board }` |
164
- | `OBFID` | Unique identifier (string, coerced from number) |
165
- | `OBFFormatVersion` | Format version string (e.g., `open-board-0.1`) |
166
- | `OBFLicense` | Licensing information |
167
- | `OBFLocaleCode` | BCP 47 locale code |
168
- | `OBFLocalizedStrings` | Key-value string translations |
169
- | `OBFStrings` | Multi-locale string translations |
145
+ | Type | Description |
146
+ | --------------------- | -------------------------------------------------------------------------------------- |
147
+ | `OBFBoard` | A single communication board |
148
+ | `OBFGrid` | Grid layout (rows, columns, order) |
149
+ | `OBFButton` | A button on the board |
150
+ | `OBFButtonAction` | Button action (spelling or specialty) |
151
+ | `OBFSpellingAction` | Spelling action (e.g., `+s`) |
152
+ | `OBFSpecialtyAction` | Specialty action (e.g., `:clear`) |
153
+ | `OBFLoadBoard` | Reference to load another board |
154
+ | `OBFMedia` | Common media properties (base for `OBFImage` and `OBFSound`) |
155
+ | `OBFImage` | An image resource (extends `OBFMedia`) |
156
+ | `OBFSound` | A sound resource (alias of `OBFMedia`) |
157
+ | `OBFSymbolInfo` | Symbol set reference |
158
+ | `OBFManifest` | OBZ package manifest |
159
+ | `ParsedOBZ` | Return type of `extractOBZ` / `loadOBZ` — `{ manifest, boards, rootBoard, resources }` |
160
+ | `LoadedBoard` | Return type of `loadBoard` — `{ format: "obz", archive } \| { format: "obf", board }` |
161
+ | `OBFID` | Unique identifier (string, coerced from number) |
162
+ | `OBFFormatVersion` | Format version string (e.g., `open-board-0.1`) |
163
+ | `OBFLicense` | Licensing information |
164
+ | `OBFLocaleCode` | BCP 47 locale code |
165
+ | `OBFLocalizedStrings` | Key-value string translations |
166
+ | `OBFStrings` | Multi-locale string translations |
170
167
 
171
168
  ### Schemas
172
169
 
@@ -181,8 +178,11 @@ import { OBFButtonSchema, OBFManifestSchema } from "@shayc/open-board-format";
181
178
  All failures throw plain `Error`. The message identifies what failed, typically with one of these prefixes:
182
179
 
183
180
  - `Invalid OBF:` — schema validation rejected an OBF board.
184
- - `Invalid OBZ:` — the package was rejected. On read: not a ZIP, missing manifest, or the manifest references a board file not in the archive. On write (`createOBZ`): `rootBoardId` matches no board, two boards share the same id, a board fails validation, two boards map the same media id to conflicting paths, a declared image/sound `path` has no matching resource, or a resource would overwrite a generated entry.
181
+ - `Invalid OBZ:` — the package was rejected.
182
+ - On read: not a ZIP, missing manifest, the manifest references a board file not in the archive, or a board's `id` differs from the ID the manifest declares for it.
183
+ - On write (`createOBZ`): `rootBoardId` matches no board, two boards share the same `id`, a board fails validation, two boards map the same media `id` to conflicting paths, a declared image/sound `path` has no matching resource, or a resource would overwrite a generated entry.
185
184
  - `Invalid manifest:` — `manifest.json` failed to parse or validate, including a `root` that is not listed in `paths.boards`.
185
+ - `Failed to unzip:` / `Failed to zip:` — the archive could not be decompressed (truncated or corrupt despite a valid ZIP signature) or compressed.
186
186
 
187
187
  When the root cause is a `JSON.parse` failure, the original error is preserved as `error.cause`. For finer-grained validation, drop one level down and use the Zod schemas directly with `safeParse` — the `issues` array tells you exactly which field failed.
188
188
 
@@ -192,9 +192,17 @@ OBZ archives are untrusted input. This library does not enforce limits on entry
192
192
 
193
193
  Found a security issue? Open a private advisory at [github.com/shayc/open-board-format/security/advisories/new](https://github.com/shayc/open-board-format/security/advisories/new).
194
194
 
195
+ ## Scope
196
+
197
+ What this library deliberately does not do:
198
+
199
+ - **No network I/O** — media referenced by `url` or `data_url` is not fetched; resolving external media is up to you.
200
+ - **No rendering** — it parses and validates data; drawing boards and playing sounds belong to your app.
201
+ - **No extraction limits or path sanitization** — see [Security](#security) before writing archive contents to disk.
202
+
195
203
  ## Versioning
196
204
 
197
- Semver; see [CHANGELOG.md](CHANGELOG.md).
205
+ Semver. Pre-1.0: minor releases may contain breaking changes — see [CHANGELOG.md](CHANGELOG.md).
198
206
 
199
207
  ## Contributing
200
208
 
package/dist/index.d.mts CHANGED
@@ -346,6 +346,9 @@ declare function stringifyOBF(board: OBFBoard): string;
346
346
  *
347
347
  * @property manifest - The package table of contents.
348
348
  * @property boards - Board ID → validated board object.
349
+ * @property rootBoard - The package's entry-point board — the one `manifest.root`
350
+ * points at, already resolved. Same object as
351
+ * `boards.get(rootBoard.id)`.
349
352
  * @property resources - Archive path → raw bytes for every entry in the archive,
350
353
  * including `manifest.json` and the `.obf` boards as well as
351
354
  * media such as images and sounds.
@@ -353,6 +356,7 @@ declare function stringifyOBF(board: OBFBoard): string;
353
356
  interface ParsedOBZ {
354
357
  manifest: OBFManifest;
355
358
  boards: Map<string, OBFBoard>;
359
+ rootBoard: OBFBoard;
356
360
  resources: Map<string, Uint8Array>;
357
361
  }
358
362
  /**
@@ -362,9 +366,9 @@ interface ParsedOBZ {
362
366
  * read the file to an `ArrayBuffer` and pass it to {@link extractOBZ} instead.
363
367
  *
364
368
  * @param file - A `File` handle pointing to an `.obz` archive.
365
- * @returns The parsed manifest, boards, and binary resources.
369
+ * @returns The parsed manifest, boards, root board, and binary resources.
366
370
  *
367
- * @throws {Error} If the file is not a valid ZIP or the manifest is missing.
371
+ * @throws {Error} Same failures as {@link extractOBZ}, which this delegates to.
368
372
  */
369
373
  declare function loadOBZ(file: File): Promise<ParsedOBZ>;
370
374
  /**
@@ -372,9 +376,12 @@ declare function loadOBZ(file: File): Promise<ParsedOBZ>;
372
376
  *
373
377
  * @param archive - The OBZ archive as an `ArrayBuffer`.
374
378
  * @returns The parsed manifest, a map of board IDs to validated boards,
375
- * and a map of file paths to their binary content.
379
+ * the resolved root board, and a map of file paths to their
380
+ * binary content.
376
381
  *
377
- * @throws {Error} If the archive is not a valid ZIP or the manifest is missing.
382
+ * @throws {Error} If the archive is not a valid ZIP, the manifest is missing,
383
+ * a board declared in the manifest is missing or fails validation, or a
384
+ * board's `id` differs from the ID the manifest declares for it.
378
385
  */
379
386
  declare function extractOBZ(archive: ArrayBuffer): Promise<ParsedOBZ>;
380
387
  /**
@@ -417,9 +424,9 @@ declare function createOBZ(boards: OBFBoard[], rootBoardId: string, resources?:
417
424
  * ```ts
418
425
  * const loaded = await loadBoard(file);
419
426
  * if (loaded.format === "obz") {
420
- * loaded.archive.boards; // ParsedOBZ
427
+ * loaded.archive.rootBoard; // home board of the ParsedOBZ archive
421
428
  * } else {
422
- * loaded.board; // OBFBoard
429
+ * loaded.board; // OBFBoard
423
430
  * }
424
431
  * ```
425
432
  */
package/dist/index.mjs CHANGED
@@ -384,9 +384,9 @@ function isZip(archive) {
384
384
  * read the file to an `ArrayBuffer` and pass it to {@link extractOBZ} instead.
385
385
  *
386
386
  * @param file - A `File` handle pointing to an `.obz` archive.
387
- * @returns The parsed manifest, boards, and binary resources.
387
+ * @returns The parsed manifest, boards, root board, and binary resources.
388
388
  *
389
- * @throws {Error} If the file is not a valid ZIP or the manifest is missing.
389
+ * @throws {Error} Same failures as {@link extractOBZ}, which this delegates to.
390
390
  */
391
391
  async function loadOBZ(file) {
392
392
  return extractOBZ(await file.arrayBuffer());
@@ -396,17 +396,22 @@ async function loadOBZ(file) {
396
396
  *
397
397
  * @param archive - The OBZ archive as an `ArrayBuffer`.
398
398
  * @returns The parsed manifest, a map of board IDs to validated boards,
399
- * and a map of file paths to their binary content.
399
+ * the resolved root board, and a map of file paths to their
400
+ * binary content.
400
401
  *
401
- * @throws {Error} If the archive is not a valid ZIP or the manifest is missing.
402
+ * @throws {Error} If the archive is not a valid ZIP, the manifest is missing,
403
+ * a board declared in the manifest is missing or fails validation, or a
404
+ * board's `id` differs from the ID the manifest declares for it.
402
405
  */
403
406
  async function extractOBZ(archive) {
404
407
  if (!isZip(archive)) throw new Error("Invalid OBZ: not a ZIP file");
405
408
  const entries = await unzip(archive);
406
409
  const manifest = extractManifest(entries);
410
+ const { boards, rootBoard } = extractBoards(manifest, entries);
407
411
  return {
408
412
  manifest,
409
- boards: extractBoards(manifest, entries),
413
+ boards,
414
+ rootBoard,
410
415
  resources: entries
411
416
  };
412
417
  }
@@ -521,13 +526,20 @@ function extractManifest(entries) {
521
526
  }
522
527
  function extractBoards(manifest, entries) {
523
528
  const boards = /* @__PURE__ */ new Map();
529
+ let rootBoard;
524
530
  for (const [id, path] of Object.entries(manifest.paths.boards)) {
525
531
  const boardBytes = entries.get(path);
526
532
  if (!boardBytes) throw new Error(`Invalid OBZ: board "${id}" declared in manifest but missing at path "${path}"`);
527
- const boardJson = new TextDecoder().decode(boardBytes);
528
- boards.set(id, parseOBF(boardJson));
533
+ const board = parseOBF(new TextDecoder().decode(boardBytes));
534
+ if (board.id !== id) throw new Error(`Invalid OBZ: board at "${path}" has id "${board.id}" but the manifest declares it as "${id}"`);
535
+ boards.set(id, board);
536
+ if (path === manifest.root) rootBoard = board;
529
537
  }
530
- return boards;
538
+ if (!rootBoard) throw new Error(`Invalid OBZ: root board "${manifest.root}" not found in paths.boards`);
539
+ return {
540
+ boards,
541
+ rootBoard
542
+ };
531
543
  }
532
544
  //#endregion
533
545
  //#region src/load-board.ts
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/schema.ts","../src/obf.ts","../src/zip.ts","../src/obz.ts","../src/load-board.ts"],"sourcesContent":["/**\n * Open Board Format (OBF) Zod Schemas\n *\n * These schemas represent the Open Board Format, designed for sharing communication boards and board sets\n * between Augmentative and Alternative Communication (AAC) applications.\n *\n * Official OBF specification: https://www.openboardformat.org/docs\n *\n * @author Shay Cojocaru\n * @license MIT\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));\nexport type OBFID = z.infer<typeof OBFIDSchema>;\n\n/**\n * Format version of the Open Board Format, e.g., 'open-board-0.1'.\n */\nexport const OBFFormatVersionSchema = z.string().regex(/^open-board-.+$/);\nexport type OBFFormatVersion = z.infer<typeof OBFFormatVersionSchema>;\n\n/**\n * Locale identifier, typically a BCP 47 language tag (e.g., 'en', 'en-US', 'fr-CA').\n * Not strictly validated — any string is accepted.\n */\nexport const OBFLocaleCodeSchema = z.string();\nexport type OBFLocaleCode = z.infer<typeof OBFLocaleCodeSchema>;\n\n/**\n * Key–value pairs mapping symbolic names to their translations in a single locale.\n */\nexport const OBFLocalizedStringsSchema = z.record(z.string(), z.string());\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);\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(/^\\+.+$/);\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);\nexport type OBFSpecialtyAction = z.infer<typeof OBFSpecialtyActionSchema>;\n\n/**\n * Union of spelling and specialty actions that a button can trigger.\n */\nexport const OBFButtonActionSchema = z.union([\n OBFSpellingActionSchema,\n OBFSpecialtyActionSchema,\n]);\nexport type OBFButtonAction = z.infer<typeof OBFButtonActionSchema>;\n\n/**\n * License terms and attribution for a resource.\n */\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\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 */\nexport const OBFMediaSchema = z.looseObject({\n /** Unique identifier for the media resource. */\n id: OBFIDSchema,\n /** Data URI containing the media data. */\n data: z.string().optional(),\n /** Path to the media file within an .obz package. */\n path: z.string().optional(),\n /** Data URL to fetch the media programmatically. */\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\nexport type OBFMedia = z.infer<typeof OBFMediaSchema>;\n\n/**\n * Reference to a symbol in a proprietary symbol set (e.g., SymbolStix).\n */\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});\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});\nexport type OBFImage = z.infer<typeof OBFImageSchema>;\n\n/**\n * Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.\n */\nexport const OBFSoundSchema = OBFMediaSchema;\nexport type OBFSound = z.infer<typeof OBFSoundSchema>;\n\n/**\n * Reference to another board, resolved by ID, path, or URL.\n */\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 /** Data URL to fetch the board programmatically. */\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\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.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 /** Action associated with the button. */\n action: OBFButtonActionSchema.optional(),\n /** List of multiple actions for the button, executed in order. */\n actions: z.array(OBFButtonActionSchema).optional(),\n /** Information to load another board when this button is activated. */\n load_board: OBFLoadBoardSchema.optional(),\n /** Background color of the button in 'rgb' or 'rgba' format. */\n background_color: z.string().optional(),\n /** Border color of the button in 'rgb' or 'rgba' format. */\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\nexport type OBFButton = z.infer<typeof OBFButtonSchema>;\n\n/**\n * Row-and-column layout that arranges buttons by their IDs.\n */\nexport const OBFGridSchema = z\n .looseObject({\n /** Number of rows in the grid. */\n rows: z.number().int().min(1),\n /** Number of columns in the grid. */\n columns: z.number().int().min(1),\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 });\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\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()),\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 });\nexport type OBFManifest = z.infer<typeof OBFManifestSchema>;\n","import 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/** Build a descriptive JSON parse-failure message, preserving the engine's reason when available. */\nexport function buildJsonParseErrorMessage(\n label: string,\n error: unknown,\n): string {\n const reason = error instanceof Error ? error.message : \"\";\n return reason\n ? `Invalid ${label}: JSON parse failed — ${reason}`\n : `Invalid ${label}: JSON parse failed`;\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 {Error} If the JSON is malformed or does not conform to the OBF schema.\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 Error(buildJsonParseErrorMessage(\"OBF\", error), { 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 {Error} If the file content is malformed or 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 {Error} If the value does not conform to the OBF schema.\n */\nexport function validateOBF(data: unknown): OBFBoard {\n const result = OBFBoardSchema.safeParse(data);\n\n if (!result.success) {\n throw new Error(`Invalid OBF: ${result.error.message}`);\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","import { unzip as fflateUnzip, zip as fflateZip } from \"fflate\";\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 used by fflate (1–9 scale). */\nconst COMPRESSION_LEVEL = 6;\n\n/**\n * Decompress a ZIP archive into a map of file paths to raw bytes.\n *\n * @param archive - The ZIP archive as an `ArrayBuffer`.\n * @returns A map of file paths to their decompressed content.\n *\n * @throws {Error} If decompression fails.\n */\nexport function unzip(archive: ArrayBuffer): Promise<Map<string, Uint8Array>> {\n return new Promise((resolve, reject) => {\n const compressed = new Uint8Array(archive);\n\n fflateUnzip(compressed, (error, entries) => {\n if (error) {\n reject(new Error(`Failed to unzip: ${error.message ?? String(error)}`));\n return;\n }\n\n const pathToBytes = new Map<string, Uint8Array>(Object.entries(entries));\n\n resolve(pathToBytes);\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 {Error} If compression fails.\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 if (error) {\n reject(new Error(`Failed to zip: ${error.message ?? String(error)}`));\n return;\n }\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","import { buildJsonParseErrorMessage, parseOBF } from \"./obf\";\nimport type { OBFBoard, OBFManifest } from \"./schema\";\nimport { OBFBoardSchema, OBFManifestSchema } from \"./schema\";\nimport { isZip, unzip, zip } from \"./zip\";\n\n/**\n * Fully extracted contents of an `.obz` archive.\n *\n * @property manifest - The package table of contents.\n * @property boards - Board ID → validated board object.\n * @property resources - Archive path → raw bytes for every entry in the archive,\n * including `manifest.json` and the `.obf` boards as well as\n * media such as images and sounds.\n */\nexport interface ParsedOBZ {\n manifest: OBFManifest;\n boards: Map<string, OBFBoard>;\n resources: Map<string, Uint8Array>;\n}\n\n/**\n * Read a `File` and extract its contents as a parsed OBZ package.\n *\n * This relies on the browser `File` API; for Node environments,\n * read the file to an `ArrayBuffer` and pass it to {@link extractOBZ} instead.\n *\n * @param file - A `File` handle pointing to an `.obz` archive.\n * @returns The parsed manifest, boards, and binary resources.\n *\n * @throws {Error} If the file is not a valid ZIP or the manifest is missing.\n */\nexport async function loadOBZ(file: File): Promise<ParsedOBZ> {\n const archive = await file.arrayBuffer();\n return extractOBZ(archive);\n}\n\n/**\n * Decompress an OBZ archive and return its manifest, boards, and resources.\n *\n * @param archive - The OBZ archive as an `ArrayBuffer`.\n * @returns The parsed manifest, a map of board IDs to validated boards,\n * and a map of file paths to their binary content.\n *\n * @throws {Error} If the archive is not a valid ZIP or the manifest is missing.\n */\nexport async function extractOBZ(archive: ArrayBuffer): Promise<ParsedOBZ> {\n if (!isZip(archive)) {\n throw new Error(\"Invalid OBZ: not a ZIP file\");\n }\n\n const entries = await unzip(archive);\n\n const manifest = extractManifest(entries);\n const boards = extractBoards(manifest, entries);\n\n return { manifest, boards, 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 {Error} If the JSON is malformed or 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 Error(buildJsonParseErrorMessage(\"manifest\", error), {\n cause: error,\n });\n }\n\n const result = OBFManifestSchema.safeParse(data);\n\n if (!result.success) {\n throw new Error(`Invalid manifest: ${result.error.message}`);\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 * @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 {Error} If `rootBoardId` does not match any of the supplied boards.\n * @throws {Error} If two supplied boards share the same id.\n * @throws {Error} If a supplied board fails schema validation.\n * @throws {Error} If two boards declare the same media id with conflicting paths.\n * @throws {Error} If a board declares an image or sound `path` with no matching entry in `resources`.\n * @throws {Error} 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 Error(\n `Invalid OBZ: rootBoardId \"${rootBoardId}\" does not match any supplied board`,\n );\n }\n\n const seenBoardIds = new Set<string>();\n for (const board of boards) {\n if (seenBoardIds.has(board.id)) {\n throw new Error(\n `Invalid OBZ: duplicate board id \"${board.id}\" — board ids must be unique within a package`,\n );\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, `boards/${board.id}.obf`]),\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: `boards/${rootBoardId}.obf`,\n paths: {\n boards: boardPaths,\n images: imagePaths,\n ...(Object.keys(soundPaths).length > 0 ? { sounds: soundPaths } : {}),\n },\n });\n\n if (!manifestResult.success) {\n throw new Error(\n `Invalid OBZ: generated manifest failed validation — ${manifestResult.error.message}`,\n );\n }\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 Error(\n `Invalid OBZ: board \"${board.id}\" failed validation — ${result.error.message}`,\n );\n }\n const path = `boards/${result.data.id}.obf`;\n entries.set(path, encoder.encode(JSON.stringify(result.data, null, 2)));\n }\n\n if (resources) {\n for (const [path, bytes] of resources) {\n if (entries.has(path)) {\n throw new Error(\n `Invalid OBZ: resource path \"${path}\" collides with a generated board or manifest entry`,\n );\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 * 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 Error(\n `Invalid OBZ: ${kind} id \"${media.id}\" maps to conflicting paths \"${existing}\" and \"${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 Error(\n `Invalid OBZ: ${kind} \"${id}\" references \"${path}\" but no matching resource was supplied`,\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 Error(\"Invalid OBZ: missing manifest.json\");\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): Map<string, OBFBoard> {\n const boards = new Map<string, OBFBoard>();\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 Error(\n `Invalid OBZ: board \"${id}\" declared in manifest but missing at path \"${path}\"`,\n );\n }\n\n const boardJson = new TextDecoder().decode(boardBytes);\n boards.set(id, parseOBF(boardJson));\n }\n\n return boards;\n}\n","import { parseOBF } from \"./obf\";\nimport { extractOBZ } from \"./obz\";\nimport type { ParsedOBZ } from \"./obz\";\nimport type { OBFBoard } from \"./schema\";\nimport { isZip } 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.boards; // ParsedOBZ\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 * The input is read once and its leading bytes are sniffed for the ZIP magic\n * prefix: a ZIP is treated as an `.obz` package, anything else as an `.obf`\n * board. This lets consumers accept either format from a single drag-and-drop,\n * file picker, or fetch response without inspecting the file extension or\n * re-deriving the OBF-vs-OBZ distinction themselves.\n *\n * @param input - A `File` handle or `ArrayBuffer` holding `.obf` or `.obz` content.\n * @returns A discriminated union tagged by `format`.\n *\n * @throws {Error} If an OBZ archive is malformed or its manifest is missing,\n * or if an OBF board is malformed or fails schema validation.\n */\nexport async function loadBoard(\n input: File | ArrayBuffer,\n): Promise<LoadedBoard> {\n const buffer =\n input instanceof ArrayBuffer ? input : await input.arrayBuffer();\n\n if (isZip(buffer)) {\n return { format: \"obz\", archive: await extractOBZ(buffer) };\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":[],"sources":["../src/schema.ts","../src/obf.ts","../src/zip.ts","../src/obz.ts","../src/load-board.ts"],"sourcesContent":["/**\n * Open Board Format (OBF) Zod Schemas\n *\n * These schemas represent the Open Board Format, designed for sharing communication boards and board sets\n * between Augmentative and Alternative Communication (AAC) applications.\n *\n * Official OBF specification: https://www.openboardformat.org/docs\n *\n * @author Shay Cojocaru\n * @license MIT\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));\nexport type OBFID = z.infer<typeof OBFIDSchema>;\n\n/**\n * Format version of the Open Board Format, e.g., 'open-board-0.1'.\n */\nexport const OBFFormatVersionSchema = z.string().regex(/^open-board-.+$/);\nexport type OBFFormatVersion = z.infer<typeof OBFFormatVersionSchema>;\n\n/**\n * Locale identifier, typically a BCP 47 language tag (e.g., 'en', 'en-US', 'fr-CA').\n * Not strictly validated — any string is accepted.\n */\nexport const OBFLocaleCodeSchema = z.string();\nexport type OBFLocaleCode = z.infer<typeof OBFLocaleCodeSchema>;\n\n/**\n * Key–value pairs mapping symbolic names to their translations in a single locale.\n */\nexport const OBFLocalizedStringsSchema = z.record(z.string(), z.string());\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);\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(/^\\+.+$/);\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);\nexport type OBFSpecialtyAction = z.infer<typeof OBFSpecialtyActionSchema>;\n\n/**\n * Union of spelling and specialty actions that a button can trigger.\n */\nexport const OBFButtonActionSchema = z.union([\n OBFSpellingActionSchema,\n OBFSpecialtyActionSchema,\n]);\nexport type OBFButtonAction = z.infer<typeof OBFButtonActionSchema>;\n\n/**\n * License terms and attribution for a resource.\n */\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\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 */\nexport const OBFMediaSchema = z.looseObject({\n /** Unique identifier for the media resource. */\n id: OBFIDSchema,\n /** Data URI containing the media data. */\n data: z.string().optional(),\n /** Path to the media file within an .obz package. */\n path: z.string().optional(),\n /** Data URL to fetch the media programmatically. */\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\nexport type OBFMedia = z.infer<typeof OBFMediaSchema>;\n\n/**\n * Reference to a symbol in a proprietary symbol set (e.g., SymbolStix).\n */\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});\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});\nexport type OBFImage = z.infer<typeof OBFImageSchema>;\n\n/**\n * Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.\n */\nexport const OBFSoundSchema = OBFMediaSchema;\nexport type OBFSound = z.infer<typeof OBFSoundSchema>;\n\n/**\n * Reference to another board, resolved by ID, path, or URL.\n */\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 /** Data URL to fetch the board programmatically. */\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\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.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 /** Action associated with the button. */\n action: OBFButtonActionSchema.optional(),\n /** List of multiple actions for the button, executed in order. */\n actions: z.array(OBFButtonActionSchema).optional(),\n /** Information to load another board when this button is activated. */\n load_board: OBFLoadBoardSchema.optional(),\n /** Background color of the button in 'rgb' or 'rgba' format. */\n background_color: z.string().optional(),\n /** Border color of the button in 'rgb' or 'rgba' format. */\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\nexport type OBFButton = z.infer<typeof OBFButtonSchema>;\n\n/**\n * Row-and-column layout that arranges buttons by their IDs.\n */\nexport const OBFGridSchema = z\n .looseObject({\n /** Number of rows in the grid. */\n rows: z.number().int().min(1),\n /** Number of columns in the grid. */\n columns: z.number().int().min(1),\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 });\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\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()),\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 });\nexport type OBFManifest = z.infer<typeof OBFManifestSchema>;\n","import 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/** Build a descriptive JSON parse-failure message, preserving the engine's reason when available. */\nexport function buildJsonParseErrorMessage(\n label: string,\n error: unknown,\n): string {\n const reason = error instanceof Error ? error.message : \"\";\n return reason\n ? `Invalid ${label}: JSON parse failed — ${reason}`\n : `Invalid ${label}: JSON parse failed`;\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 {Error} If the JSON is malformed or does not conform to the OBF schema.\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 Error(buildJsonParseErrorMessage(\"OBF\", error), { 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 {Error} If the file content is malformed or 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 {Error} If the value does not conform to the OBF schema.\n */\nexport function validateOBF(data: unknown): OBFBoard {\n const result = OBFBoardSchema.safeParse(data);\n\n if (!result.success) {\n throw new Error(`Invalid OBF: ${result.error.message}`);\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","import { unzip as fflateUnzip, zip as fflateZip } from \"fflate\";\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 used by fflate (1–9 scale). */\nconst COMPRESSION_LEVEL = 6;\n\n/**\n * Decompress a ZIP archive into a map of file paths to raw bytes.\n *\n * @param archive - The ZIP archive as an `ArrayBuffer`.\n * @returns A map of file paths to their decompressed content.\n *\n * @throws {Error} If decompression fails.\n */\nexport function unzip(archive: ArrayBuffer): Promise<Map<string, Uint8Array>> {\n return new Promise((resolve, reject) => {\n const compressed = new Uint8Array(archive);\n\n fflateUnzip(compressed, (error, entries) => {\n if (error) {\n reject(new Error(`Failed to unzip: ${error.message ?? String(error)}`));\n return;\n }\n\n const pathToBytes = new Map<string, Uint8Array>(Object.entries(entries));\n\n resolve(pathToBytes);\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 {Error} If compression fails.\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 if (error) {\n reject(new Error(`Failed to zip: ${error.message ?? String(error)}`));\n return;\n }\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","import { buildJsonParseErrorMessage, parseOBF } from \"./obf\";\nimport type { OBFBoard, OBFManifest } from \"./schema\";\nimport { OBFBoardSchema, OBFManifestSchema } from \"./schema\";\nimport { isZip, unzip, zip } from \"./zip\";\n\n/**\n * Fully extracted contents of an `.obz` archive.\n *\n * @property manifest - The package table of contents.\n * @property boards - Board ID → validated board object.\n * @property rootBoard - The package's entry-point board — the one `manifest.root`\n * points at, already resolved. Same object as\n * `boards.get(rootBoard.id)`.\n * @property resources - Archive path → raw bytes for every entry in the archive,\n * including `manifest.json` and the `.obf` boards as well as\n * media such as images and sounds.\n */\nexport interface ParsedOBZ {\n manifest: OBFManifest;\n boards: Map<string, OBFBoard>;\n rootBoard: OBFBoard;\n resources: Map<string, Uint8Array>;\n}\n\n/**\n * Read a `File` and extract its contents as a parsed OBZ package.\n *\n * This relies on the browser `File` API; for Node environments,\n * read the file to an `ArrayBuffer` and pass it to {@link extractOBZ} instead.\n *\n * @param file - A `File` handle pointing to an `.obz` archive.\n * @returns The parsed manifest, boards, root board, and binary resources.\n *\n * @throws {Error} Same failures as {@link extractOBZ}, which this delegates to.\n */\nexport async function loadOBZ(file: File): Promise<ParsedOBZ> {\n const archive = await file.arrayBuffer();\n return extractOBZ(archive);\n}\n\n/**\n * Decompress an OBZ archive and return its manifest, boards, and resources.\n *\n * @param archive - The OBZ archive as an `ArrayBuffer`.\n * @returns The parsed manifest, a map of board IDs to validated boards,\n * the resolved root board, and a map of file paths to their\n * binary content.\n *\n * @throws {Error} If the archive is not a valid ZIP, the manifest is missing,\n * a board declared in the manifest is missing or fails validation, or a\n * board's `id` differs from the ID the manifest declares for it.\n */\nexport async function extractOBZ(archive: ArrayBuffer): Promise<ParsedOBZ> {\n if (!isZip(archive)) {\n throw new Error(\"Invalid OBZ: not a ZIP file\");\n }\n\n const entries = await unzip(archive);\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 {Error} If the JSON is malformed or 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 Error(buildJsonParseErrorMessage(\"manifest\", error), {\n cause: error,\n });\n }\n\n const result = OBFManifestSchema.safeParse(data);\n\n if (!result.success) {\n throw new Error(`Invalid manifest: ${result.error.message}`);\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 * @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 {Error} If `rootBoardId` does not match any of the supplied boards.\n * @throws {Error} If two supplied boards share the same id.\n * @throws {Error} If a supplied board fails schema validation.\n * @throws {Error} If two boards declare the same media id with conflicting paths.\n * @throws {Error} If a board declares an image or sound `path` with no matching entry in `resources`.\n * @throws {Error} 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 Error(\n `Invalid OBZ: rootBoardId \"${rootBoardId}\" does not match any supplied board`,\n );\n }\n\n const seenBoardIds = new Set<string>();\n for (const board of boards) {\n if (seenBoardIds.has(board.id)) {\n throw new Error(\n `Invalid OBZ: duplicate board id \"${board.id}\" — board ids must be unique within a package`,\n );\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, `boards/${board.id}.obf`]),\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: `boards/${rootBoardId}.obf`,\n paths: {\n boards: boardPaths,\n images: imagePaths,\n ...(Object.keys(soundPaths).length > 0 ? { sounds: soundPaths } : {}),\n },\n });\n\n if (!manifestResult.success) {\n throw new Error(\n `Invalid OBZ: generated manifest failed validation — ${manifestResult.error.message}`,\n );\n }\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 Error(\n `Invalid OBZ: board \"${board.id}\" failed validation — ${result.error.message}`,\n );\n }\n const path = `boards/${result.data.id}.obf`;\n entries.set(path, encoder.encode(JSON.stringify(result.data, null, 2)));\n }\n\n if (resources) {\n for (const [path, bytes] of resources) {\n if (entries.has(path)) {\n throw new Error(\n `Invalid OBZ: resource path \"${path}\" collides with a generated board or manifest entry`,\n );\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 * 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 Error(\n `Invalid OBZ: ${kind} id \"${media.id}\" maps to conflicting paths \"${existing}\" and \"${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 Error(\n `Invalid OBZ: ${kind} \"${id}\" references \"${path}\" but no matching resource was supplied`,\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 Error(\"Invalid OBZ: missing manifest.json\");\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 Error(\n `Invalid OBZ: board \"${id}\" declared in manifest but missing at path \"${path}\"`,\n );\n }\n\n const boardJson = new TextDecoder().decode(boardBytes);\n const board = parseOBF(boardJson);\n\n if (board.id !== id) {\n throw new Error(\n `Invalid OBZ: board at \"${path}\" has id \"${board.id}\" but the manifest declares it as \"${id}\"`,\n );\n }\n\n boards.set(id, board);\n\n if (path === manifest.root) {\n rootBoard = board;\n }\n }\n\n if (!rootBoard) {\n // Unreachable for validated manifests: the schema requires `root` to be\n // listed in `paths.boards`. Kept as a guard for hand-built manifests.\n throw new Error(\n `Invalid OBZ: root board \"${manifest.root}\" not found in paths.boards`,\n );\n }\n\n return { boards, rootBoard };\n}\n","import { parseOBF } from \"./obf\";\nimport { extractOBZ } from \"./obz\";\nimport type { ParsedOBZ } from \"./obz\";\nimport type { OBFBoard } from \"./schema\";\nimport { isZip } 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 }\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 * The input is read once and its leading bytes are sniffed for the ZIP magic\n * prefix: a ZIP is treated as an `.obz` package, anything else as an `.obf`\n * board. This lets consumers accept either format from a single drag-and-drop,\n * file picker, or fetch response without inspecting the file extension or\n * re-deriving the OBF-vs-OBZ distinction themselves.\n *\n * @param input - A `File` handle or `ArrayBuffer` holding `.obf` or `.obz` content.\n * @returns A discriminated union tagged by `format`.\n *\n * @throws {Error} If an OBZ archive is malformed or its manifest is missing,\n * or if an OBF board is malformed or fails schema validation.\n */\nexport async function loadBoard(\n input: File | ArrayBuffer,\n): Promise<LoadedBoard> {\n const buffer =\n input instanceof ArrayBuffer ? input : await input.arrayBuffer();\n\n if (isZip(buffer)) {\n return { format: \"obz\", archive: await extractOBZ(buffer) };\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": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A TypeScript toolkit for Open Board Format — the open standard for Augmentative and Alternative Communication (AAC) boards.",
5
5
  "license": "MIT",
6
6
  "author": "Shay Cojocaru <shayc@outlook.com>",