@shayc/open-board-format 1.3.1 → 1.3.3

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,17 @@
1
1
  # @shayc/open-board-format
2
2
 
3
+ ## 1.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - a58a344: Rewrite the README as a decision-first package guide with accurate API, validation, error, and security documentation.
8
+
9
+ ## 1.3.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 6656f2f: Validate the Changesets v3 release flow after hardening CI checkout credentials.
14
+
3
15
  ## 1.3.1
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -2,28 +2,22 @@
2
2
 
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
- [![License: MIT](https://img.shields.io/npm/l/@shayc/open-board-format.svg)](LICENSE)
6
5
 
7
- A TypeScript toolkit that parses, validates, and creates [Open Board Format](https://www.openboardformat.org/) files — 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 handles both, and powers [AAC Board AI](https://aacboard.app), an offline-first AAC web app.
6
+ Parse, validate, and create [Open Board Format](https://www.openboardformat.org/) (OBF) communication boards (`.obf`) and archives (`.obz`) for augmentative and alternative communication (AAC) applications in TypeScript or JavaScript.
8
7
 
9
- ```
10
- my-board.obz
11
- ├── manifest.json ← table of contents: root board + id-to-path maps
12
- ├── boards/
13
- │ └── home.obf ← one JSON board per file
14
- ├── images/
15
- │ └── dog.png
16
- └── sounds/
17
- └── hello.mp3
18
- ```
8
+ Add AAC board import and export without implementing schemas, manifests, or archive handling yourself.
19
9
 
20
- - **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.
21
- - **Browser and Node.js 22+** — pure ESM, works against `File`, `Blob`, `ArrayBuffer`, or any typed-array view (e.g. Node's `Buffer`).
22
- - **One entry point for either format** — `loadBoard` sniffs the bytes and tells you whether it found an `.obf` board or an `.obz` package.
23
- - **Spec-faithful round trips** — unknown fields are preserved rather than stripped, so vendor extensions allowed by the OBF spec survive `parseOBF` → `stringifyOBF`.
24
- - **Small footprint** — ~11 kB min+gzip including the single runtime dependency ([fflate](https://github.com/101arrowz/fflate)); Zod is a peer, and the package is tree-shakeable with no side effects.
10
+ The package handles format detection, validation, archive creation, and schema access so applications can focus on board experiences instead of file handling.
25
11
 
26
- **Contents:** [Install](#install) · [Quick start](#quick-start) · [Usage](#usage) · [API](#api) · [Errors](#errors) · [Security](#security) · [Scope](#scope) · [Versioning](#versioning) · [Contributing](#contributing) · [Related](#related) · [License](#license)
12
+ ## Features
13
+
14
+ - Load OBF or OBZ through one byte-based format detection API.
15
+ - Create OBZ archives with generated manifests and validated media resources.
16
+ - Use exported [Zod](https://zod.dev/) schemas and inferred TypeScript types.
17
+ - Preserve unknown fields, including vendor extensions.
18
+ - Run as pure ESM in Node.js 22+ and modern browsers.
19
+
20
+ It focuses on board data and archives only. It does not render boards, play media, fetch remote resources, or resolve navigation and media references.
27
21
 
28
22
  ## Install
29
23
 
@@ -31,75 +25,77 @@ my-board.obz
31
25
  npm install @shayc/open-board-format zod
32
26
  ```
33
27
 
34
- `zod` 4 is a peer dependency — npm 7+ installs it automatically, but pnpm and Yarn users should add it explicitly (as shown above).
35
-
36
- ESM only — CommonJS (`require`) is not supported.
28
+ `zod ^4.4.3` is a required peer dependency.
37
29
 
38
30
  ## Quick start
39
31
 
40
32
  ```ts
41
33
  import { loadBoard } from "@shayc/open-board-format";
34
+ import type { BinaryInput, OBFBoard } from "@shayc/open-board-format";
42
35
 
43
- // `file` came from drag-and-drop or <input type="file"> — could be .obf or .obz
44
- const loaded = await loadBoard(file);
36
+ export async function loadRootBoard(input: BinaryInput): Promise<OBFBoard> {
37
+ const loaded = await loadBoard(input);
45
38
 
46
- if (loaded.format === "obf") {
47
- console.log(loaded.board.buttons.length);
48
- } else {
49
- console.log(loaded.archive.rootBoard.buttons.length);
39
+ return loaded.format === "obf" ? loaded.board : loaded.archive.rootBoard;
50
40
  }
51
41
  ```
52
42
 
53
- `loadBoard` accepts a `File`, `Blob`, `ArrayBuffer`, or any typed-array view (e.g. a Node `Buffer`), and throws on invalid input (see [Errors](#errors)). Already holding a JSON string? `parseOBF(json)` returns a validated `OBFBoard` directly.
43
+ `BinaryInput` accepts `File`, `Blob`, `ArrayBuffer`, and `ArrayBufferView` values, including browser files, fetched blobs, typed arrays, and Node.js `Buffer` values. `loadBoard` detects the format from the bytes, not the filename.
54
44
 
55
- In Node.js, read the file first — there's no `File` API outside the browser, but `readFile`'s `Buffer` works directly:
45
+ ## Formats
56
46
 
57
- ```ts
58
- import { readFile } from "node:fs/promises";
59
- import { loadBoard } from "@shayc/open-board-format";
47
+ - **OBF (`.obf`)** is one JSON communication board.
48
+ - **OBZ (`.obz`)** is a ZIP archive containing one or more boards and optional media.
60
49
 
61
- const loaded = await loadBoard(await readFile("my-board.obz"));
50
+ ```text
51
+ my-board.obz
52
+ ├── manifest.json
53
+ ├── boards/
54
+ │ └── home.obf
55
+ ├── images/
56
+ │ └── dog.png
57
+ └── sounds/
58
+ └── hello.mp3
62
59
  ```
63
60
 
64
- ## Usage
61
+ Every OBZ archive requires `manifest.json` at its root, even when it contains only one board.
65
62
 
66
- ### Which function do I need?
63
+ ### Which function should I call?
67
64
 
68
- - **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.
69
- - **You have a package of boards plus media (OBZ).** Use `extractOBZ` for a `File`, `Blob`, `ArrayBuffer`, or typed-array view (`loadOBZ` is the same thing, `File`-only); `createOBZ` to build a new one.
70
- - **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.
65
+ - Unknown file: `loadBoard(input)`
66
+ - Known `.obf` file: `loadOBF(file)`
67
+ - Known `.obz` input: `extractOBZ(input)`
68
+ - Creating an archive: `createOBZ(...)`
71
69
 
72
- ### Read an OBZ package
70
+ See the [API reference](#api-reference) for the complete function list. Here, `File` means the Web Platform object, not a filesystem path.
73
71
 
74
- ```ts
75
- import { loadOBZ, extractOBZ } from "@shayc/open-board-format";
72
+ ## Examples
76
73
 
77
- // From a File (e.g. drag-and-drop)
78
- const { rootBoard, boards, resources } = await loadOBZ(file);
74
+ ### Read an OBZ archive
79
75
 
80
- // Or from anything else — ArrayBuffer, Blob, Node Buffer, etc.
81
- const parsed = await extractOBZ(buffer);
76
+ ```ts
77
+ import { extractOBZ } from "@shayc/open-board-format";
82
78
 
83
- // Untrusted input? Cap declared uncompressed sizes (see Security)
84
- const guarded = await extractOBZ(buffer, {
85
- limits: { maxTotalOriginalSize: 500e6 },
86
- });
79
+ const archive = await extractOBZ(obzBytes);
87
80
  ```
88
81
 
89
- `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.
82
+ The returned `ParsedOBZ` contains:
90
83
 
91
- Resources are raw bytes. To display an image in the browser:
84
+ - `manifest`: the validated OBZ manifest.
85
+ - `rootBoard`: the board referenced by `manifest.root`.
86
+ - `boards`: a `Map` keyed by board ID.
87
+ - `resources`: a `Map` containing the raw bytes of every file entry.
92
88
 
93
- ```ts
94
- const bytes = resources.get("images/hello.png")!;
95
- const url = URL.createObjectURL(new Blob([bytes]));
96
- ```
89
+ `resources` includes the manifest, board files, media, and unrelated extra files. Directory-marker entries are omitted.
90
+
91
+ For untrusted archives, configure [extraction limits](#extraction-limits).
97
92
 
98
- ### Create an OBZ package
93
+ ### Create an OBZ archive
99
94
 
100
- 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.
95
+ Buttons reference media by ID. Image and sound records declare archive paths, while the `resources` map supplies the bytes stored at those paths.
101
96
 
102
97
  ```ts
98
+ import { readFile, writeFile } from "node:fs/promises";
103
99
  import { createOBZ } from "@shayc/open-board-format";
104
100
  import type { OBFBoard } from "@shayc/open-board-format";
105
101
 
@@ -111,198 +107,221 @@ const board: OBFBoard = {
111
107
  images: [{ id: "img-1", path: "images/hello.png" }],
112
108
  };
113
109
 
114
- const pngBytes = new Uint8Array(/* ... */);
110
+ const pngBytes = await readFile("hello.png");
115
111
  const resources = new Map([["images/hello.png", pngBytes]]);
116
112
 
117
113
  const blob = await createOBZ([board], "board-1", resources);
114
+ await writeFile("my-board.obz", new Uint8Array(await blob.arrayBuffer()));
118
115
  ```
119
116
 
120
- The `manifest.json` is generated for you boards are written to `boards/<id>.obf` (the id is percent-encoded, so it's always a safe filename), and `rootBoardId` (the second argument) selects the home board.
117
+ `createOBZ` generates the manifest automatically, writes boards to `boards/<encoded-id>.obf`, and uses `rootBoardId` as the archive's entry board.
121
118
 
122
- ### Validate with Zod directly
119
+ Before writing the archive, it checks board IDs, the root board, generated paths, media-path conflicts, and declared media resources. It does not resolve `load_board`, `image_id`, or `sound_id` references.
120
+
121
+ ### Validate a board
123
122
 
124
123
  ```ts
125
124
  import { OBFBoardSchema } from "@shayc/open-board-format";
126
125
 
127
- const result = OBFBoardSchema.safeParse(data);
126
+ export const validateBoard = (value: unknown) =>
127
+ OBFBoardSchema.safeParse(value);
128
+ ```
129
+
130
+ Every public OBF data model has a matching Zod schema export with a `Schema` suffix. The schemas can also be composed with Zod APIs such as `.extend()` and `.pick()`.
128
131
 
129
- if (result.success) {
130
- console.log(result.data.buttons);
131
- } else {
132
- console.error(result.error.issues);
133
- }
132
+ ## Validation behavior
133
+
134
+ Validation returns a parsed copy of the input. Known fields may be normalized during parsing:
135
+
136
+ - Numeric IDs become strings.
137
+ - Empty optional IDs, URLs, and email addresses become `undefined`.
138
+ - Unknown properties are preserved at every loose-object level, with or without an `ext_` prefix.
139
+
140
+ Structural validation checks:
141
+
142
+ - URL and email fields are syntax-checked.
143
+ - Grid dimensions must be integers from 1 through 100.
144
+ - `grid.order` must exactly match the declared row and column counts.
145
+ - Positioned buttons must provide `top`, `left`, `width`, and `height`, each between 0 and 1.
146
+ - Format versions must match `open-board-*`; they are not restricted to `open-board-0.1`.
147
+ - An OBZ manifest root must appear in `paths.boards`.
148
+
149
+ Validation is not a complete OBF conformance or graph-integrity check. It does not enforce:
150
+
151
+ - Unique button, image, or sound IDs.
152
+ - Resolution of `grid.order`, `image_id`, `sound_id`, or `load_board` references.
153
+ - A consistent positioning mode across every button on a board.
154
+ - BCP 47 locale syntax, color syntax, MIME correctness, or safe HTML.
155
+ - During extraction, the existence of manifest-declared media files or their agreement with board media records.
156
+
157
+ Add application-specific checks after parsing when those guarantees matter.
158
+
159
+ ## API reference
160
+
161
+ ### Functions
162
+
163
+ #### Board data
164
+
165
+ | Function | Returns | Behavior |
166
+ | --------------------- | ------------------- | ----------------------------------------------------------- |
167
+ | `parseOBF(json)` | `OBFBoard` | Parse JSON and validate a board; strips a leading UTF-8 BOM |
168
+ | `validateOBF(value)` | `OBFBoard` | Validate and normalize an unknown value |
169
+ | `stringifyOBF(board)` | `string` | Serialize as two-space JSON without revalidating |
170
+ | `loadOBF(file)` | `Promise<OBFBoard>` | Read a `File`, then parse and validate it |
171
+
172
+ #### Archives and format detection
173
+
174
+ | Function | Returns | Behavior |
175
+ | -------------------------------------------- | ---------------------- | ------------------------------------------------------------------- |
176
+ | `loadBoard(input, options?)` | `Promise<LoadedBoard>` | Detect OBF or OBZ from the bytes, then load it |
177
+ | `loadOBZ(file, options?)` | `Promise<ParsedOBZ>` | `File` convenience wrapper around `extractOBZ` |
178
+ | `extractOBZ(input, options?)` | `Promise<ParsedOBZ>` | Extract and validate the manifest and every manifest-declared board |
179
+ | `createOBZ(boards, rootBoardId, resources?)` | `Promise<Blob>` | Validate and package boards and resources with a generated manifest |
180
+ | `parseManifest(json)` | `OBFManifest` | Parse and validate manifest JSON |
181
+
182
+ #### ZIP utilities
183
+
184
+ | Function | Returns | Behavior |
185
+ | ------------------------- | ---------------------------------- | -------------------------------------------------------- |
186
+ | `isZip(buffer)` | `boolean` | Check whether an `ArrayBuffer` has a ZIP signature |
187
+ | `zip(entries)` | `Promise<Uint8Array>` | Compress a map of paths to `Uint8Array` or `ArrayBuffer` |
188
+ | `unzip(buffer, options?)` | `Promise<Map<string, Uint8Array>>` | Extract an `ArrayBuffer` and omit directory markers |
189
+
190
+ ### Types and schemas
191
+
192
+ `LoadedBoard` is a discriminated union:
193
+
194
+ ```ts
195
+ { format: "obf", board: OBFBoard }
196
+ | { format: "obz", archive: ParsedOBZ }
134
197
  ```
135
198
 
136
- ## API
137
-
138
- One naming convention covers the whole surface: `parse*` takes a JSON string, `validate*` takes an already-parsed object, `load*` takes a browser `File`, `stringify*` returns a JSON string — and `extractOBZ`/`loadBoard` also accept a `Blob`, `ArrayBuffer`, or typed-array view (e.g. a Node `Buffer`), for use outside the browser.
139
-
140
- ### OBF (single board)
141
-
142
- | Function | Returns | Description |
143
- | --------------------- | ------------------- | ------------------------------------------------------------ |
144
- | `parseOBF(json)` | `OBFBoard` | Parse a JSON string into a validated `OBFBoard` |
145
- | `validateOBF(data)` | `OBFBoard` | Validate an unknown object as `OBFBoard` (throws on failure) |
146
- | `stringifyOBF(board)` | `string` | Serialize an `OBFBoard` to a JSON string |
147
- | `loadOBF(file)` | `Promise<OBFBoard>` | Load an `OBFBoard` from a browser `File` |
148
-
149
- ### OBZ (board package)
150
-
151
- | Function | Returns | Description |
152
- | -------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------- |
153
- | `loadOBZ(file, options?)` | `Promise<ParsedOBZ>` | Load an OBZ package from a browser `File` |
154
- | `extractOBZ(archive, options?)` | `Promise<ParsedOBZ>` | Extract boards, manifest, root board, and resources from a `File`, `Blob`, `ArrayBuffer`, or typed-array view |
155
- | `createOBZ(boards, rootBoardId, resources?)` | `Promise<Blob>` | Create an OBZ package as a `Blob` |
156
- | `parseManifest(json)` | `OBFManifest` | Parse a `manifest.json` string into a validated `OBFManifest` |
157
-
158
- ### Format detection
159
-
160
- | Function | Returns | Description |
161
- | ---------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
162
- | `loadBoard(input, options?)` | `Promise<LoadedBoard>` | Detect OBF vs OBZ from a `File`, `Blob`, `ArrayBuffer`, or typed-array view and load it; returns a `LoadedBoard` union |
163
-
164
- ### Utilities
165
-
166
- | Function | Returns | Description |
167
- | -------------------------- | ---------------------------------- | -------------------------------------------------------- |
168
- | `isZip(archive)` | `boolean` | Check if an `ArrayBuffer` starts with a ZIP magic number |
169
- | `zip(entries)` | `Promise<Uint8Array>` | Create a ZIP from a map of paths to buffers |
170
- | `unzip(archive, options?)` | `Promise<Map<string, Uint8Array>>` | Extract a ZIP into a map of paths to `Uint8Array` |
171
-
172
- ### Types
173
-
174
- | Type | Description |
175
- | --------------------- | -------------------------------------------------------------------------------------------------------------- |
176
- | `OBFBoard` | A single communication board |
177
- | `OBFGrid` | Grid layout (rows, columns, order) |
178
- | `OBFButton` | A button on the board |
179
- | `OBFButtonAction` | Button action (spelling or specialty) |
180
- | `OBFSpellingAction` | Spelling action (e.g., `+s`) |
181
- | `OBFSpecialtyAction` | Specialty action (e.g., `:clear`) |
182
- | `OBFLoadBoard` | Reference to load another board |
183
- | `OBFMedia` | Common media properties (base for `OBFImage` and `OBFSound`) |
184
- | `OBFImage` | An image resource (extends `OBFMedia`) |
185
- | `OBFSound` | A sound resource (alias of `OBFMedia`) |
186
- | `OBFSymbolInfo` | Symbol set reference |
187
- | `OBFManifest` | OBZ package manifest |
188
- | `ParsedOBZ` | Return type of `extractOBZ` / `loadOBZ` — `{ manifest, boards, rootBoard, resources }` |
189
- | `LoadedBoard` | Return type of `loadBoard` — `{ format: "obz", archive } \| { format: "obf", board }` |
190
- | `BinaryInput` | Input type of `loadBoard` / `extractOBZ` — `File \| Blob \| ArrayBuffer \| ArrayBufferView` |
191
- | `UnzipLimits` | Optional extraction caps — `{ maxEntrySize?, maxTotalOriginalSize?, maxEntries? }` (see [Security](#security)) |
192
- | `UnzipOptions` | Options for `unzip` / `extractOBZ` / `loadOBZ` / `loadBoard` — `{ limits?: UnzipLimits }` |
193
- | `OBFID` | Unique identifier (string, coerced from number) |
194
- | `OBFFormatVersion` | Format version string (e.g., `open-board-0.1`) |
195
- | `OBFLicense` | Licensing information |
196
- | `OBFLocaleCode` | BCP 47 locale code |
197
- | `OBFLocalizedStrings` | Key-value string translations |
198
- | `OBFStrings` | Multi-locale string translations |
199
-
200
- ### Schemas
201
-
202
- Every type above except `ParsedOBZ`, `LoadedBoard`, `BinaryInput`, `UnzipLimits`, and `UnzipOptions` is exported alongside a matching Zod schema with a `Schema` suffix — `OBFBoard` → `OBFBoardSchema`, `OBFManifest` → `OBFManifestSchema`, and so on. Import any of them to validate with `safeParse`/`parse` or to compose into your own schemas:
199
+ `ParsedOBZ` provides the validated archive contents:
203
200
 
204
201
  ```ts
205
- import { OBFButtonSchema, OBFManifestSchema } from "@shayc/open-board-format";
202
+ interface ParsedOBZ {
203
+ manifest: OBFManifest;
204
+ boards: Map<string, OBFBoard>;
205
+ rootBoard: OBFBoard;
206
+ resources: Map<string, Uint8Array>;
207
+ }
206
208
  ```
207
209
 
208
- ## Errors
210
+ Main exports include:
211
+
212
+ - Board, action, media, metadata, and manifest types.
213
+ - Matching Zod schemas, including `OBFBoardSchema` and `OBFManifestSchema`.
214
+ - Input and archive types: `BinaryInput`, `ParsedOBZ`, and `LoadedBoard`.
215
+ - Structured errors through `OBFError` and its related types.
216
+
217
+ ### Errors
209
218
 
210
- Every failure throws an `OBFError`. Branch on `error.info.code` — a discriminated union where each `code` carries exactly the fields relevant to that failure. Don't match on `error.message`; the message is human-readable and may change between releases.
219
+ Expected parsing, validation, and archive-domain failures from the high-level APIs use `OBFError`.
220
+
221
+ Branch on `error.info.code`, not `error.message`.
211
222
 
212
223
  ```ts
213
224
  import { loadBoard, OBFError } from "@shayc/open-board-format";
225
+ import type { BinaryInput } from "@shayc/open-board-format";
226
+
227
+ export async function openBoard(input: BinaryInput) {
228
+ try {
229
+ return await loadBoard(input);
230
+ } catch (error) {
231
+ if (!(error instanceof OBFError)) throw error;
214
232
 
215
- try {
216
- await loadBoard(file);
217
- } catch (error) {
218
- if (!(error instanceof OBFError)) throw error;
219
-
220
- switch (error.info.code) {
221
- case "missing-resource":
222
- // `kind`, `mediaId`, and `path` are all typed and present here
223
- console.warn(`Missing ${error.info.kind} at ${error.info.path}`);
224
- break;
225
- case "invalid-board":
226
- // `issues` is the Zod issue list — which field failed and why
233
+ if (error.info.code === "invalid-board") {
227
234
  console.error(error.info.issues);
228
- break;
229
- default:
235
+ } else {
230
236
  console.error(error.message);
237
+ }
238
+
239
+ throw error;
231
240
  }
232
241
  }
233
242
  ```
234
243
 
235
- The `code` values, grouped by what they describe:
236
-
237
- | Group | `info.code` | Key fields (on `info`) |
238
- | ----------- | ------------------- | --------------------------------------------- |
239
- | Decoding | `not-json` | `source` |
240
- | | `not-zip` | |
241
- | | `unreadable-zip` | — |
242
- | | `archive-too-large` | `limit`, `path`, and the tripped cap's fields |
243
- | Validation | `invalid-board` | `issues`, `boardId?` |
244
- | | `invalid-manifest` | `issues` |
245
- | Read (OBZ) | `missing-manifest` | |
246
- | | `missing-board` | `boardId`, `path` |
247
- | | `board-id-mismatch` | `path`, `declaredId`, `actualId` |
248
- | Write (OBZ) | `unknown-root` | `rootBoardId` |
249
- | | `duplicate-board` | `boardId` |
250
- | | `missing-resource` | `kind`, `mediaId`, `path` |
251
- | | `conflicting-paths` | `kind`, `mediaId`, `paths` |
252
- | | `path-collision` | `path` |
253
- | | `zip-failed` | |
254
- | Internal | `internal` | `detail` |
255
-
256
- `OBFErrorInfo` and `OBFErrorCode` are exported for exhaustive handling.
257
-
258
- - The underlying error, when there is one, is always on the standard `error.cause` — never duplicated on `info`.
259
- - Validation failures (`invalid-board`, `invalid-manifest`) put the `ZodError` on `error.cause`, so `z.treeifyError(error.cause)` gives nested, UI-friendly output, while `info.issues` (Zod's issue type, re-exported as `OBFIssue`) gives you the flat list directly.
260
- - For `not-json` / `*-zip` failures, `error.cause` is the underlying parser or fflate error.
261
- - An `internal` code signals a bug in this library that callers can't recover from please report it.
262
-
263
- **Re-zipping an extracted `.obz` by hand?** Zip the folder's _contents_, not the folder itself. If `manifest.json` ends up nested inside a top-level folder in the archive, extraction fails with `missing-manifest` even though the file is right there.
244
+ <details>
245
+ <summary><strong>Error codes</strong></summary>
246
+
247
+ | Area | `info.code` | Additional fields |
248
+ | -------------- | ------------------- | -------------------------------------------------- |
249
+ | Decoding | `not-json` | `source` |
250
+ | Decoding | `not-zip` | — |
251
+ | Decoding | `unreadable-zip` | |
252
+ | Limits | `archive-too-large` | `limit`, `path`, and fields for the exceeded limit |
253
+ | Validation | `invalid-board` | `issues`, `boardId?` |
254
+ | Validation | `invalid-manifest` | `issues` |
255
+ | OBZ extraction | `missing-manifest` | |
256
+ | OBZ extraction | `missing-board` | `boardId`, `path` |
257
+ | OBZ extraction | `board-id-mismatch` | `path`, `declaredId`, `actualId` |
258
+ | OBZ creation | `unknown-root` | `rootBoardId` |
259
+ | OBZ creation | `duplicate-board` | `boardId` |
260
+ | OBZ creation | `missing-resource` | `kind`, `mediaId`, `path` |
261
+ | OBZ creation | `conflicting-paths` | `kind`, `mediaId`, `paths` |
262
+ | OBZ creation | `path-collision` | `path` |
263
+ | OBZ creation | `zip-failed` | |
264
+ | Internal | `internal` | `detail` |
265
+
266
+ </details>
267
+
268
+ Validation failures expose the underlying `ZodError` as `error.cause` and provide its flat issue list through `error.info.issues`.
269
+
270
+ `not-json`, `unreadable-zip`, and `zip-failed` expose the underlying parser or ZIP error as `error.cause`. An `internal` error indicates a library invariant failure and should be reported.
271
+
272
+ Direct schema `.parse()` calls throw `ZodError` rather than `OBFError`.
264
273
 
265
274
  ## Security
266
275
 
267
- OBZ archives are untrusted input. To guard against zip bombs, pass `limits` (via `UnzipOptions`) to `loadBoard` / `loadOBZ` / `extractOBZ` / `unzip`:
276
+ Treat OBZ archives and their contents as untrusted input.
277
+
278
+ ### Extraction limits
268
279
 
269
280
  ```ts
270
- const parsed = await extractOBZ(buffer, {
271
- limits: {
272
- maxEntrySize: 100e6, // any single entry, in bytes
273
- maxTotalOriginalSize: 500e6, // sum of all entries, in bytes
274
- maxEntries: 10_000, // entry count, including directory entries
275
- },
276
- });
281
+ import { extractOBZ } from "@shayc/open-board-format";
282
+ import type { BinaryInput } from "@shayc/open-board-format";
283
+
284
+ export function extractUntrusted(input: BinaryInput) {
285
+ return extractOBZ(input, {
286
+ limits: {
287
+ // Examples only—choose limits appropriate for your application.
288
+ maxEntrySize: 100 * 1024 ** 2, // 100 MiB
289
+ maxTotalOriginalSize: 500 * 1024 ** 2, // 500 MiB
290
+ maxEntries: 10_000,
291
+ },
292
+ });
293
+ }
277
294
  ```
278
295
 
279
- Size limits are checked per entry against the uncompressed size declared in the archive's ZIP metadata, before that entry is inflated; `maxEntries` caps how many entries are processed at all. Entries accepted before a later entry trips a limit have already been inflated, but total allocation stays bounded by the caps. Exceeding a limit aborts extraction and rejects with an `OBFError` whose code is `archive-too-large` (`info` carries `limit`, the entry `path` that tripped it, and `maxBytes`/`declaredBytes` for the size limits or `maxEntries`/`entryCount` for the count). No limits are applied by default. A lying header can't force larger allocations — fflate allocates output buffers at exactly the declared size — so the declared-size caps bound memory use.
280
-
281
- Entry paths are not sanitized — if you write extracted resources to disk, validate paths yourself first to avoid directory traversal.
296
+ Extraction limits are optional and disabled by default. Entry and total-size limits are checked against ZIP metadata before inflation, while `maxEntries` caps the number of entries processed.
282
297
 
283
- 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).
298
+ These limits reduce risk, but they are not strict memory guarantees. ZIP metadata can be dishonest, and stored entries can produce more output than their declared uncompressed size.
284
299
 
285
- ## Scope
300
+ Also enforce a limit on the compressed archive size before passing it to this package. Use process isolation or a streaming design when your threat model requires a strict memory boundary.
286
301
 
287
- What this library deliberately does not do:
302
+ ### Other boundaries
288
303
 
289
- - **No network I/O** media referenced by `url` or `data_url` is not fetched; resolving external media is up to you.
290
- - **No rendering** it parses and validates data; drawing boards and playing sounds belong to your app.
291
- - **No default extraction limits, no path sanitization** size caps are opt-in via `UnzipOptions`; see [Security](#security) before writing archive contents to disk.
292
- - **No referential integrity checks** — a `grid.order` id with no matching button, or an `image_id`/`sound_id` with no matching image/sound, is not flagged. Resolving references is up to your rendering layer.
304
+ - Archive entry paths are not sanitized. Validate them before writing files to disk to prevent directory traversal.
305
+ - `description_html` is not sanitized. Sanitize it before inserting it into the DOM.
306
+ - URLs and `data_url` values are validated syntactically but are never fetched.
293
307
 
294
- ## Versioning
308
+ Found a vulnerability? Email [shayc@outlook.com](mailto:shayc@outlook.com) rather than opening a public issue.
295
309
 
296
- Semver. The public API — every exported function, type, and Zod schema — is stable; breaking changes ship as major releases. See [CHANGELOG.md](CHANGELOG.md).
310
+ ## Runtime
297
311
 
298
- ## Contributing
312
+ - Pure ESM for Node.js `>=22` and modern browsers; CommonJS is unsupported.
313
+ - Browser environments must provide `Blob`, `File`, `TextEncoder`, and `TextDecoder`.
314
+ - `fflate` is the only runtime dependency; `zod ^4.4.3` is a peer dependency.
315
+ - CI covers Node.js 22, 24, and 26. Browser engines are not currently tested in CI.
299
316
 
300
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup (Node 22+, Vitest, the changeset workflow).
317
+ ## Project
301
318
 
302
- ## Related
319
+ The public API follows semantic versioning. Breaking changes to exported APIs, schemas, or documented behavior ship as major releases.
303
320
 
304
- - [Open Board Format specification](https://www.openboardformat.org/docs) — the official standard and format documentation. A 1:1 mirror is kept at [docs/external/open-board-format.md](docs/external/open-board-format.md) for offline reference.
305
- - [AAC Board AI](https://github.com/shayc/aac-board-ai) an offline-first AAC web app built on this package, using on-device browser AI for grammar, tone, and translation ([live app](https://aacboard.app)).
321
+ - **Changelog:** See [CHANGELOG.md](CHANGELOG.md).
322
+ - **Support:** [Open an issue](https://github.com/shayc/open-board-format/issues) with a minimal reproduction, package version, runtime, and bundler where applicable.
323
+ - **Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md) for development commands, tests, and the changeset workflow.
324
+ - **Specification:** See the [official OBF documentation](https://www.openboardformat.org/docs) or the included [offline mirror](docs/external/open-board-format.md).
306
325
 
307
326
  ## License
308
327
 
package/dist/index.d.mts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
-
3
2
  //#region src/schema.d.ts
4
3
  /** Unique board-element identifier, coerced to a non-empty string. */
5
4
  declare const OBFIDSchema: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
@@ -313,65 +312,105 @@ type OBFIssue = z.core.$ZodIssue;
313
312
  * never duplicated here. The only optional field is `invalid-board`'s
314
313
  * `boardId`, absent when validation runs on a value with no known id.
315
314
  */
316
- type OBFErrorInfo = /** Input was not parseable JSON. */{
315
+ type OBFErrorInfo =
316
+ /** Input was not parseable JSON. */
317
+ {
317
318
  code: "not-json";
318
319
  source: "board" | "manifest";
319
- } /** An OBZ archive was expected, but the bytes are not a ZIP. */ | {
320
+ } |
321
+ /** An OBZ archive was expected, but the bytes are not a ZIP. */
322
+ {
320
323
  code: "not-zip";
321
- } /** A ZIP archive could not be decompressed. */ | {
324
+ } |
325
+ /** A ZIP archive could not be decompressed. */
326
+ {
322
327
  code: "unreadable-zip";
323
- } /** An entry or the archive's declared uncompressed total exceeds a caller-supplied limit. */ | {
328
+ } |
329
+ /** An entry or the archive's declared uncompressed total exceeds a caller-supplied limit. */
330
+ {
324
331
  code: "archive-too-large";
325
- limit: "maxEntrySize" | "maxTotalOriginalSize"; /** The cap that was exceeded, in bytes. */
326
- maxBytes: number; /** The declared size that exceeded it: the entry's size, or the running total. */
327
- declaredBytes: number; /** The archive entry whose declaration tripped the limit. */
332
+ limit: "maxEntrySize" | "maxTotalOriginalSize";
333
+ /** The cap that was exceeded, in bytes. */
334
+ maxBytes: number;
335
+ /** The declared size that exceeded it: the entry's size, or the running total. */
336
+ declaredBytes: number;
337
+ /** The archive entry whose declaration tripped the limit. */
328
338
  path: string;
329
- } /** The archive has more entries than a caller-supplied limit allows. */ | {
339
+ } |
340
+ /** The archive has more entries than a caller-supplied limit allows. */
341
+ {
330
342
  code: "archive-too-large";
331
- limit: "maxEntries"; /** The cap that was exceeded, as an entry count. */
332
- maxEntries: number; /** The running entry count that exceeded it. */
333
- entryCount: number; /** The archive entry that tripped the limit. */
343
+ limit: "maxEntries";
344
+ /** The cap that was exceeded, as an entry count. */
345
+ maxEntries: number;
346
+ /** The running entry count that exceeded it. */
347
+ entryCount: number;
348
+ /** The archive entry that tripped the limit. */
334
349
  path: string;
335
- } /** A board failed schema validation. `boardId` is set when known. */ | {
350
+ } |
351
+ /** A board failed schema validation. `boardId` is set when known. */
352
+ {
336
353
  code: "invalid-board";
337
354
  boardId?: string;
338
355
  issues: readonly OBFIssue[];
339
- } /** A manifest failed schema validation. */ | {
356
+ } |
357
+ /** A manifest failed schema validation. */
358
+ {
340
359
  code: "invalid-manifest";
341
360
  issues: readonly OBFIssue[];
342
- } /** The archive has no `manifest.json`. */ | {
361
+ } |
362
+ /** The archive has no `manifest.json`. */
363
+ {
343
364
  code: "missing-manifest";
344
- } /** A board the manifest declares is absent from the archive. */ | {
365
+ } |
366
+ /** A board the manifest declares is absent from the archive. */
367
+ {
345
368
  code: "missing-board";
346
369
  boardId: string;
347
370
  path: string;
348
- } /** A board's `id` disagrees with the id the manifest declares for it. */ | {
371
+ } |
372
+ /** A board's `id` disagrees with the id the manifest declares for it. */
373
+ {
349
374
  code: "board-id-mismatch";
350
375
  path: string;
351
376
  declaredId: string;
352
377
  actualId: string;
353
- } /** `rootBoardId` matches none of the supplied boards. */ | {
378
+ } |
379
+ /** `rootBoardId` matches none of the supplied boards. */
380
+ {
354
381
  code: "unknown-root";
355
382
  rootBoardId: string;
356
- } /** Two supplied boards share the same `id`. */ | {
383
+ } |
384
+ /** Two supplied boards share the same `id`. */
385
+ {
357
386
  code: "duplicate-board";
358
387
  boardId: string;
359
- } /** A board declares a media `path` with no matching resource. */ | {
388
+ } |
389
+ /** A board declares a media `path` with no matching resource. */
390
+ {
360
391
  code: "missing-resource";
361
392
  kind: "image" | "sound";
362
393
  mediaId: string;
363
394
  path: string;
364
- } /** Two boards declare the same media id with different paths. */ | {
395
+ } |
396
+ /** Two boards declare the same media id with different paths. */
397
+ {
365
398
  code: "conflicting-paths";
366
399
  kind: "image" | "sound";
367
400
  mediaId: string;
368
401
  paths: [string, string];
369
- } /** A supplied resource would overwrite a generated board or the manifest. */ | {
402
+ } |
403
+ /** A supplied resource would overwrite a generated board or the manifest. */
404
+ {
370
405
  code: "path-collision";
371
406
  path: string;
372
- } /** The archive could not be compressed. */ | {
407
+ } |
408
+ /** The archive could not be compressed. */
409
+ {
373
410
  code: "zip-failed";
374
- } /** An internal invariant was violated — a bug in this library; please report. */ | {
411
+ } |
412
+ /** An internal invariant was violated — a bug in this library; please report. */
413
+ {
375
414
  code: "internal";
376
415
  detail: string;
377
416
  };
@@ -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 }\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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"}
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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shayc/open-board-format",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
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>",
@@ -63,19 +63,19 @@
63
63
  "zod": "^4.4.3"
64
64
  },
65
65
  "devDependencies": {
66
- "@changesets/cli": "^2.31.0",
66
+ "@changesets/cli": "^3.0.0",
67
67
  "@eslint/js": "^10.0.1",
68
68
  "@types/node": "^22.0.0",
69
- "@vitest/coverage-v8": "^4.1.8",
70
- "eslint": "^10.4.1",
69
+ "@vitest/coverage-v8": "^4.1.10",
70
+ "eslint": "^10.8.1",
71
71
  "eslint-config-prettier": "^10.1.8",
72
- "globals": "^17.6.0",
73
- "prettier": "^3.8.4",
74
- "publint": "^0.3.21",
75
- "tsdown": "^0.22.2",
72
+ "globals": "^17.11.0",
73
+ "prettier": "^3.9.6",
74
+ "publint": "^0.3.23",
75
+ "tsdown": "^0.22.14",
76
76
  "typescript": "~6.0.3",
77
- "typescript-eslint": "^8.61.0",
78
- "vitest": "^4.1.8",
77
+ "typescript-eslint": "^8.67.0",
78
+ "vitest": "^4.1.10",
79
79
  "zod": "^4.4.3"
80
80
  },
81
81
  "publishConfig": {