@shayc/open-board-format 1.3.2 → 1.3.4

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +216 -215
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @shayc/open-board-format
2
2
 
3
+ ## 1.3.4
4
+
5
+ ### Patch Changes
6
+
7
+ - ea8b3f6: Clarify the README introduction and `loadBoard` usage example.
8
+
9
+ ## 1.3.3
10
+
11
+ ### Patch Changes
12
+
13
+ - a58a344: Rewrite the README as a decision-first package guide with accurate API, validation, error, and security documentation.
14
+
3
15
  ## 1.3.2
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -2,28 +2,19 @@
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
+ A TypeScript/JavaScript library for parsing, validating, and creating [Open Board Format](https://www.openboardformat.org/) (OBF) communication boards (`.obf`) and archives (`.obz`) for AAC applications.
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 Open Board Format import and export without implementing schemas, manifests, or archive handling yourself.
9
+
10
+ ## Features
19
11
 
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.
12
+ - Load OBF or OBZ through one byte-based format detection API.
13
+ - Create OBZ archives with generated manifests and validated media resources.
14
+ - Use exported [Zod](https://zod.dev/) schemas and inferred TypeScript types.
15
+ - Preserve unknown fields, including vendor extensions.
25
16
 
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)
17
+ It focuses on board data and archives only. It does not render boards, play media, fetch remote resources, or resolve navigation and media references.
27
18
 
28
19
  ## Install
29
20
 
@@ -31,183 +22,179 @@ my-board.obz
31
22
  npm install @shayc/open-board-format zod
32
23
  ```
33
24
 
34
- `zod` 4 is a peer dependency — npm 7+ installs it automatically, but pnpm and Yarn users should add it explicitly (as shown above).
25
+ `zod ^4.4.3` is a required peer dependency.
35
26
 
36
- ESM only CommonJS (`require`) is not supported.
27
+ Works in browsers and Node.js. Browser `File` uploads and Node.js `Buffer` values use the same loading API. Pure ESM; CommonJS is not supported.
37
28
 
38
29
  ## Quick start
39
30
 
40
31
  ```ts
41
32
  import { loadBoard } from "@shayc/open-board-format";
42
33
 
43
- // `file` came from drag-and-drop or <input type="file"> — could be .obf or .obz
44
- const loaded = await loadBoard(file);
34
+ const loaded = await loadBoard(input);
35
+ ```
45
36
 
46
- if (loaded.format === "obf") {
47
- console.log(loaded.board.buttons.length);
48
- } else {
49
- console.log(loaded.archive.rootBoard.buttons.length);
50
- }
37
+ `loadBoard` accepts a `File`, `Blob`, `ArrayBuffer`, or `ArrayBufferView` and detects the format from the bytes, not the filename. It returns a TypeScript discriminated union: OBF files contain a board directly, while OBZ files contain an archive whose `rootBoard` is the entry point.
38
+
39
+ ```ts
40
+ const board = loaded.format === "obf" ? loaded.board : loaded.archive.rootBoard;
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
+ ## Formats
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
+ - **OBF (`.obf`)** is one JSON communication board.
46
+ - **OBZ (`.obz`)** is a ZIP archive containing one or more boards and optional media.
47
+
48
+ ```text
49
+ my-board.obz
50
+ ├── manifest.json
51
+ ├── boards/
52
+ │ └── home.obf
53
+ ├── images/
54
+ │ └── dog.png
55
+ └── sounds/
56
+ └── hello.mp3
57
+ ```
58
+
59
+ Every OBZ archive requires `manifest.json` at its root, even when it contains only one board.
60
+
61
+ ### Which function should I call?
62
+
63
+ - Unknown file: `loadBoard(input)`
64
+ - Known `.obf` file: `loadOBF(file)`
65
+ - Known `.obz` input: `extractOBZ(input)`
66
+ - Creating an archive: `createOBZ(...)`
67
+
68
+ See the [API reference](#api-reference) for the complete function list. Here, `File` means the Web Platform object, not a filesystem path.
69
+
70
+ ## Examples
71
+
72
+ ### Read an OBZ archive
56
73
 
57
74
  ```ts
58
- import { readFile } from "node:fs/promises";
59
- import { loadBoard } from "@shayc/open-board-format";
75
+ import { extractOBZ } from "@shayc/open-board-format";
60
76
 
61
- const loaded = await loadBoard(await readFile("my-board.obz"));
77
+ const archive = await extractOBZ(obzBytes);
62
78
  ```
63
79
 
64
- ## Usage
80
+ The returned `ParsedOBZ` contains:
65
81
 
66
- ### Which function do I need?
82
+ - `manifest`: the validated OBZ manifest.
83
+ - `rootBoard`: the board referenced by `manifest.root`.
84
+ - `boards`: a `Map` keyed by board ID.
85
+ - `resources`: a `Map` containing the raw bytes of every file entry.
67
86
 
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.
87
+ `resources` includes the manifest, board files, media, and unrelated extra files. Directory-marker entries are omitted.
71
88
 
72
- ### Read an OBZ package
89
+ For untrusted archives, configure [extraction limits](#extraction-limits).
73
90
 
74
- ```ts
75
- import { loadOBZ, extractOBZ } from "@shayc/open-board-format";
91
+ ### Create an OBZ archive
76
92
 
77
- // From a File (e.g. drag-and-drop)
78
- const { rootBoard, boards, resources } = await loadOBZ(file);
93
+ Given an existing board and its media resources:
79
94
 
80
- // Or from anything else — ArrayBuffer, Blob, Node Buffer, etc.
81
- const parsed = await extractOBZ(buffer);
95
+ ```ts
96
+ import { createOBZ } from "@shayc/open-board-format";
82
97
 
83
- // Untrusted input? Cap declared uncompressed sizes (see Security)
84
- const guarded = await extractOBZ(buffer, {
85
- limits: { maxTotalOriginalSize: 500e6 },
86
- });
98
+ const blob = await createOBZ([existingBoard], existingBoard.id, resources);
87
99
  ```
88
100
 
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.
101
+ `createOBZ` generates the manifest automatically, writes boards to `boards/<encoded-id>.obf`, and uses `rootBoardId` as the archive's entry board.
90
102
 
91
- Resources are raw bytes. To display an image in the browser:
103
+ ### Validate a board
92
104
 
93
105
  ```ts
94
- const bytes = resources.get("images/hello.png")!;
95
- const url = URL.createObjectURL(new Blob([bytes]));
106
+ import { OBFBoardSchema } from "@shayc/open-board-format";
107
+
108
+ export const validateBoard = (value: unknown) =>
109
+ OBFBoardSchema.safeParse(value);
96
110
  ```
97
111
 
98
- ### Create an OBZ package
112
+ 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()`.
99
113
 
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.
114
+ ## Validation details
101
115
 
102
- ```ts
103
- import { createOBZ } from "@shayc/open-board-format";
104
- import type { OBFBoard } from "@shayc/open-board-format";
116
+ Validation returns a parsed copy of the input. Known fields may be normalized during parsing:
105
117
 
106
- const board: OBFBoard = {
107
- format: "open-board-0.1",
108
- id: "board-1",
109
- buttons: [{ id: "btn-1", label: "Hello", image_id: "img-1" }],
110
- grid: { rows: 1, columns: 1, order: [["btn-1"]] },
111
- images: [{ id: "img-1", path: "images/hello.png" }],
112
- };
118
+ - Numeric IDs become strings.
119
+ - Empty optional IDs, URLs, and email addresses become `undefined`.
120
+ - Unknown properties are preserved at every loose-object level, with or without an `ext_` prefix.
113
121
 
114
- const pngBytes = new Uint8Array(/* ... */);
115
- const resources = new Map([["images/hello.png", pngBytes]]);
122
+ Structural validation checks:
116
123
 
117
- const blob = await createOBZ([board], "board-1", resources);
118
- ```
124
+ - URL and email fields are syntax-checked.
125
+ - Grid dimensions must be integers from 1 through 100.
126
+ - `grid.order` must exactly match the declared row and column counts.
127
+ - Positioned buttons must provide `top`, `left`, `width`, and `height`, each between 0 and 1.
128
+ - Format versions must match `open-board-*`; they are not restricted to `open-board-0.1`.
129
+ - An OBZ manifest root must appear in `paths.boards`.
119
130
 
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.
131
+ Validation is not a complete OBF conformance or graph-integrity check. It does not enforce:
121
132
 
122
- ### Validate with Zod directly
133
+ - Unique button, image, or sound IDs.
134
+ - Resolution of `grid.order`, `image_id`, `sound_id`, or `load_board` references.
135
+ - A consistent positioning mode across every button on a board.
136
+ - BCP 47 locale syntax, color syntax, MIME correctness, or safe HTML.
137
+ - During extraction, the existence of manifest-declared media files or their agreement with board media records.
123
138
 
124
- ```ts
125
- import { OBFBoardSchema } from "@shayc/open-board-format";
139
+ Add application-specific checks after parsing when those guarantees matter.
126
140
 
127
- const result = OBFBoardSchema.safeParse(data);
141
+ ## API reference
128
142
 
129
- if (result.success) {
130
- console.log(result.data.buttons);
131
- } else {
132
- console.error(result.error.issues);
133
- }
143
+ ### High-level API
144
+
145
+ #### Board data
146
+
147
+ | Function | Returns | Behavior |
148
+ | --------------------- | ------------------- | ----------------------------------------------------------- |
149
+ | `parseOBF(json)` | `OBFBoard` | Parse JSON and validate a board; strips a leading UTF-8 BOM |
150
+ | `validateOBF(value)` | `OBFBoard` | Validate and normalize an unknown value |
151
+ | `stringifyOBF(board)` | `string` | Serialize as two-space JSON without revalidating |
152
+ | `loadOBF(file)` | `Promise<OBFBoard>` | Read a `File`, then parse and validate it |
153
+
154
+ #### Archives and format detection
155
+
156
+ | Function | Returns | Behavior |
157
+ | -------------------------------------------- | ---------------------- | ------------------------------------------------------------------- |
158
+ | `loadBoard(input, options?)` | `Promise<LoadedBoard>` | Detect OBF or OBZ from the bytes, then load it |
159
+ | `loadOBZ(file, options?)` | `Promise<ParsedOBZ>` | `File` convenience wrapper around `extractOBZ` |
160
+ | `extractOBZ(input, options?)` | `Promise<ParsedOBZ>` | Extract and validate the manifest and every manifest-declared board |
161
+ | `createOBZ(boards, rootBoardId, resources?)` | `Promise<Blob>` | Validate and package boards and resources with a generated manifest |
162
+ | `parseManifest(json)` | `OBFManifest` | Parse and validate manifest JSON |
163
+
164
+ Before writing an archive, `createOBZ` 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.
165
+
166
+ ### Types and schemas
167
+
168
+ `LoadedBoard` is a discriminated union:
169
+
170
+ ```ts
171
+ { format: "obf", board: OBFBoard }
172
+ | { format: "obz", archive: ParsedOBZ }
134
173
  ```
135
174
 
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:
175
+ `ParsedOBZ` provides the validated archive contents:
203
176
 
204
177
  ```ts
205
- import { OBFButtonSchema, OBFManifestSchema } from "@shayc/open-board-format";
178
+ interface ParsedOBZ {
179
+ manifest: OBFManifest;
180
+ boards: Map<string, OBFBoard>;
181
+ rootBoard: OBFBoard;
182
+ resources: Map<string, Uint8Array>;
183
+ }
206
184
  ```
207
185
 
208
- ## Errors
186
+ Main exports include:
209
187
 
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.
188
+ - Board, action, media, metadata, and manifest types.
189
+ - Matching Zod schemas, including `OBFBoardSchema` and `OBFManifestSchema`.
190
+ - Input and archive types: `BinaryInput`, `ParsedOBZ`, and `LoadedBoard`.
191
+ - Structured errors through `OBFError` and its related types.
192
+
193
+ ### Errors
194
+
195
+ Expected parsing, validation, and archive-domain failures from the high-level APIs use `OBFError`.
196
+
197
+ Branch on `error.info.code`, not `error.message`.
211
198
 
212
199
  ```ts
213
200
  import { loadBoard, OBFError } from "@shayc/open-board-format";
@@ -215,94 +202,108 @@ import { loadBoard, OBFError } from "@shayc/open-board-format";
215
202
  try {
216
203
  await loadBoard(file);
217
204
  } 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
227
- console.error(error.info.issues);
228
- break;
229
- default:
230
- console.error(error.message);
205
+ if (error instanceof OBFError) {
206
+ console.error(error.info.code);
231
207
  }
208
+
209
+ throw error;
232
210
  }
233
211
  ```
234
212
 
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.
213
+ <details>
214
+ <summary><strong>Error codes</strong></summary>
215
+
216
+ | Area | `info.code` | Additional fields |
217
+ | -------------- | ------------------- | -------------------------------------------------- |
218
+ | Decoding | `not-json` | `source` |
219
+ | Decoding | `not-zip` | — |
220
+ | Decoding | `unreadable-zip` | |
221
+ | Limits | `archive-too-large` | `limit`, `path`, and fields for the exceeded limit |
222
+ | Validation | `invalid-board` | `issues`, `boardId?` |
223
+ | Validation | `invalid-manifest` | `issues` |
224
+ | OBZ extraction | `missing-manifest` | |
225
+ | OBZ extraction | `missing-board` | `boardId`, `path` |
226
+ | OBZ extraction | `board-id-mismatch` | `path`, `declaredId`, `actualId` |
227
+ | OBZ creation | `unknown-root` | `rootBoardId` |
228
+ | OBZ creation | `duplicate-board` | `boardId` |
229
+ | OBZ creation | `missing-resource` | `kind`, `mediaId`, `path` |
230
+ | OBZ creation | `conflicting-paths` | `kind`, `mediaId`, `paths` |
231
+ | OBZ creation | `path-collision` | `path` |
232
+ | OBZ creation | `zip-failed` | |
233
+ | Internal | `internal` | `detail` |
234
+
235
+ </details>
236
+
237
+ Validation failures expose the underlying `ZodError` as `error.cause` and provide its flat issue list through `error.info.issues`.
238
+
239
+ `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.
240
+
241
+ Direct schema `.parse()` calls throw `ZodError` rather than `OBFError`.
242
+
243
+ <details>
244
+ <summary><strong>Low-level ZIP utilities</strong></summary>
245
+
246
+ The following exports are available for advanced archive workflows:
247
+
248
+ | Function | Returns | Behavior |
249
+ | ------------------------- | ---------------------------------- | -------------------------------------------------------- |
250
+ | `isZip(buffer)` | `boolean` | Check whether an `ArrayBuffer` has a ZIP signature |
251
+ | `zip(entries)` | `Promise<Uint8Array>` | Compress a map of paths to `Uint8Array` or `ArrayBuffer` |
252
+ | `unzip(buffer, options?)` | `Promise<Map<string, Uint8Array>>` | Extract an `ArrayBuffer` and omit directory markers |
253
+
254
+ </details>
264
255
 
265
256
  ## Security
266
257
 
267
- OBZ archives are untrusted input. To guard against zip bombs, pass `limits` (via `UnzipOptions`) to `loadBoard` / `loadOBZ` / `extractOBZ` / `unzip`:
258
+ Treat OBZ archives and their contents as untrusted input.
259
+
260
+ ### Extraction limits
268
261
 
269
262
  ```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
- });
263
+ import { extractOBZ } from "@shayc/open-board-format";
264
+ import type { BinaryInput } from "@shayc/open-board-format";
265
+
266
+ export function extractUntrusted(input: BinaryInput) {
267
+ return extractOBZ(input, {
268
+ limits: {
269
+ // Examples only—choose limits appropriate for your application.
270
+ maxEntrySize: 100 * 1024 ** 2, // 100 MiB
271
+ maxTotalOriginalSize: 500 * 1024 ** 2, // 500 MiB
272
+ maxEntries: 10_000,
273
+ },
274
+ });
275
+ }
277
276
  ```
278
277
 
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.
278
+ 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
279
 
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).
280
+ 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
281
 
285
- ## Scope
282
+ 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
283
 
287
- What this library deliberately does not do:
284
+ ### Other boundaries
288
285
 
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.
286
+ - Archive entry paths are not sanitized. Validate them before writing files to disk to prevent directory traversal.
287
+ - `description_html` is not sanitized. Sanitize it before inserting it into the DOM.
288
+ - URLs and `data_url` values are validated syntactically but are never fetched.
293
289
 
294
- ## Versioning
290
+ Found a vulnerability? Email [shayc@outlook.com](mailto:shayc@outlook.com) rather than opening a public issue.
295
291
 
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).
292
+ ## Runtime
297
293
 
298
- ## Contributing
294
+ - Pure ESM for Node.js `>=22` and modern browsers; CommonJS is unsupported.
295
+ - Browser environments must provide `Blob`, `File`, `TextEncoder`, and `TextDecoder`.
296
+ - `fflate` is the only runtime dependency; `zod ^4.4.3` is a peer dependency.
297
+ - CI covers Node.js 22, 24, and 26. Browser engines are not currently tested in CI.
299
298
 
300
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup (Node 22+, Vitest, the changeset workflow).
299
+ ## Project
301
300
 
302
- ## Related
301
+ The public API follows semantic versioning. Breaking changes to exported APIs, schemas, or documented behavior ship as major releases.
303
302
 
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)).
303
+ - **Changelog:** See [CHANGELOG.md](CHANGELOG.md).
304
+ - **Support:** [Open an issue](https://github.com/shayc/open-board-format/issues) with a minimal reproduction, package version, runtime, and bundler where applicable.
305
+ - **Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md) for development commands, tests, and the changeset workflow.
306
+ - **Specification:** See the [official OBF documentation](https://www.openboardformat.org/docs) or the included [offline mirror](docs/external/open-board-format.md).
306
307
 
307
308
  ## License
308
309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shayc/open-board-format",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
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>",