@shayc/open-board-format 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +92 -84
- package/dist/index.d.mts +120 -52
- package/dist/index.mjs +94 -64
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @shayc/open-board-format
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e7c9b47: docs: polish public API JSDoc for better IntelliSense
|
|
8
|
+
|
|
9
|
+
- Document every exported type alias (`OBFBoard`, `OBFButton`, …) so hover tooltips show a description instead of just the expanded type
|
|
10
|
+
- Move `ParsedOBZ` field docs from `@property` tags onto the fields themselves so they surface in editors
|
|
11
|
+
- Clarify `data_url` as an API endpoint (not a `data:` URI) and note it is outside the `data` → `path` → `url` fallback chain
|
|
12
|
+
- Document `action` vs `actions` precedence per the OBF spec
|
|
13
|
+
- Correct fflate compression-level scale (0–9), standardize `@throws` phrasing, example quoting, and module headers; tag `buildJsonParseErrorMessage` as `@internal`
|
|
14
|
+
|
|
15
|
+
## 0.4.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- bdf9fc5: Add `rootBoard` to `ParsedOBZ`: `extractOBZ` and `loadOBZ` now resolve the package's entry-point board (the one `manifest.root` points at) and return it directly, so consumers no longer need to invert `manifest.paths.boards` to find the home board.
|
|
20
|
+
|
|
21
|
+
Extraction is also stricter: each board's `id` must match its key in `manifest.paths.boards`. Archives where they disagree now throw `Invalid OBZ: board at "<path>" has id "<id>" but the manifest declares it as "<key>"`. Such archives were previously accepted, but their boards were unreachable by `load_board.id` navigation, so this turns a silent inconsistency into a clear error.
|
|
22
|
+
|
|
3
23
|
## 0.3.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# `@shayc/open-board-format`
|
|
2
2
|
|
|
3
|
-
A TypeScript toolkit for [Open Board Format](https://www.openboardformat.org/) — the open standard for Augmentative and Alternative Communication (AAC) boards. Parse, validate, and create OBF boards and OBZ packages, all backed by [Zod](https://zod.dev/) schemas with full TypeScript types inferred.
|
|
4
|
-
|
|
5
3
|
[](https://www.npmjs.com/package/@shayc/open-board-format)
|
|
6
4
|
[](https://github.com/shayc/open-board-format/actions/workflows/ci.yml)
|
|
7
5
|
[](LICENSE)
|
|
8
6
|
|
|
9
|
-
OBF (`.obf`) is a JSON file describing a single communication board
|
|
7
|
+
A TypeScript toolkit for [Open Board Format](https://www.openboardformat.org/) — the open standard for Augmentative and Alternative Communication (AAC) boards. OBF (`.obf`) is a JSON file describing a single communication board: buttons, images, sounds, grid layout, metadata. OBZ (`.obz`) is a ZIP archive bundling one or more boards with their media and a `manifest.json`. This package parses, validates, and creates both.
|
|
8
|
+
|
|
9
|
+
- **Typed end to end** — every type is inferred from a [Zod](https://zod.dev/) schema, and every schema is exported for `safeParse` or composing into your own contracts.
|
|
10
|
+
- **Browser and Node.js 22+** — pure ESM, works against `File` and `ArrayBuffer`.
|
|
11
|
+
- **One entry point for either format** — `loadBoard` sniffs the bytes and tells you whether it found an `.obf` board or an `.obz` package.
|
|
12
|
+
- **Spec-faithful round trips** — unknown fields are preserved rather than stripped, so vendor extensions allowed by the OBF spec survive `parseOBF` → `stringifyOBF`.
|
|
13
|
+
- **Small footprint** — two runtime dependencies (Zod and [fflate](https://github.com/101arrowz/fflate)), tree-shakeable, no side effects.
|
|
10
14
|
|
|
11
15
|
## Install
|
|
12
16
|
|
|
@@ -14,87 +18,78 @@ OBF (`.obf`) is a JSON file describing a single communication board — buttons,
|
|
|
14
18
|
npm install @shayc/open-board-format
|
|
15
19
|
```
|
|
16
20
|
|
|
21
|
+
ESM only — CommonJS (`require`) is not supported.
|
|
22
|
+
|
|
17
23
|
## Quick start
|
|
18
24
|
|
|
19
25
|
```ts
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
const board = parseOBF(jsonString);
|
|
23
|
-
console.log(board.id, board.buttons.length);
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
`parseOBF` throws on invalid input; the returned value is a fully typed `OBFBoard`. For other input shapes (already-parsed object, browser `File`, OBZ archive), see [Overview](#overview).
|
|
27
|
-
|
|
28
|
-
## Overview
|
|
29
|
-
|
|
30
|
-
Two file types; pick the entry point by what you have:
|
|
31
|
-
|
|
32
|
-
- **OBF** is a single board (a JSON object). Use `parseOBF` for a JSON string, `validateOBF` for an already-parsed object, `loadOBF` for a browser `File`. `stringifyOBF` serializes back out.
|
|
33
|
-
- **OBZ** is a package of boards plus media (a ZIP archive). Use `loadOBZ` for a `File`, `extractOBZ` for an `ArrayBuffer`, `createOBZ` to build a new one.
|
|
26
|
+
import { loadBoard } from "@shayc/open-board-format";
|
|
34
27
|
|
|
35
|
-
|
|
28
|
+
// `file` came from drag-and-drop or <input type="file"> — could be .obf or .obz
|
|
29
|
+
const loaded = await loadBoard(file);
|
|
36
30
|
|
|
37
|
-
|
|
31
|
+
if (loaded.format === "obf") {
|
|
32
|
+
console.log(loaded.board.buttons.length);
|
|
33
|
+
} else {
|
|
34
|
+
console.log(loaded.archive.rootBoard.buttons.length);
|
|
35
|
+
}
|
|
36
|
+
```
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
`loadBoard` accepts a `File` or `ArrayBuffer` and throws on invalid input (see [Errors](#errors)). Already holding a JSON string? `parseOBF(json)` returns a validated `OBFBoard` directly.
|
|
40
39
|
|
|
41
|
-
##
|
|
40
|
+
## Usage
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
- **Runtime:** browser or Node 22+ — works against `File`, `ArrayBuffer`, and `Blob`.
|
|
42
|
+
### Which function do I need?
|
|
45
43
|
|
|
46
|
-
|
|
44
|
+
- **You have a single board (OBF).** Use `parseOBF` for a JSON string, `validateOBF` for an already-parsed object, `loadOBF` for a browser `File`. `stringifyOBF` serializes back out.
|
|
45
|
+
- **You have a package of boards plus media (OBZ).** Use `loadOBZ` for a `File`, `extractOBZ` for an `ArrayBuffer`, `createOBZ` to build a new one.
|
|
46
|
+
- **You don't know which you have.** Use `loadBoard` — it sniffs the bytes and returns a `{ format, ... }` union so you don't have to inspect the file extension yourself.
|
|
47
47
|
|
|
48
|
-
###
|
|
48
|
+
### Read an OBZ package
|
|
49
49
|
|
|
50
50
|
```ts
|
|
51
51
|
import { loadOBZ, extractOBZ } from "@shayc/open-board-format";
|
|
52
52
|
|
|
53
53
|
// From a File (e.g. drag-and-drop)
|
|
54
|
-
const {
|
|
54
|
+
const { rootBoard, boards, resources } = await loadOBZ(file);
|
|
55
55
|
|
|
56
56
|
// Or from an ArrayBuffer (e.g. fetch response)
|
|
57
57
|
const parsed = await extractOBZ(buffer);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`rootBoard` is the package's home board — the one `manifest.root` points at, already resolved. `boards` is keyed by board ID and `resources` by archive path; the `manifest` is also returned if you need the raw table of contents.
|
|
61
|
+
|
|
62
|
+
Resources are raw bytes. To display an image in the browser:
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
const
|
|
64
|
+
```ts
|
|
65
|
+
const bytes = resources.get("images/hello.png")!;
|
|
66
|
+
const url = URL.createObjectURL(new Blob([bytes]));
|
|
61
67
|
```
|
|
62
68
|
|
|
63
69
|
### Create an OBZ package
|
|
64
70
|
|
|
71
|
+
Buttons reference media by ID (`image_id`, `sound_id`); the board's `images`/`sounds` entries carry the archive `path`; the resources map supplies the bytes for each path. Every `path` a board declares must have a matching resource entry, or `createOBZ` throws.
|
|
72
|
+
|
|
65
73
|
```ts
|
|
66
74
|
import { createOBZ } from "@shayc/open-board-format";
|
|
67
75
|
import type { OBFBoard } from "@shayc/open-board-format";
|
|
68
76
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
];
|
|
77
|
+
const board: OBFBoard = {
|
|
78
|
+
format: "open-board-0.1",
|
|
79
|
+
id: "board-1",
|
|
80
|
+
buttons: [{ id: "btn-1", label: "Hello", image_id: "img-1" }],
|
|
81
|
+
grid: { rows: 1, columns: 1, order: [["btn-1"]] },
|
|
82
|
+
images: [{ id: "img-1", path: "images/hello.png" }],
|
|
83
|
+
};
|
|
77
84
|
|
|
78
85
|
const pngBytes = new Uint8Array(/* ... */);
|
|
79
|
-
const resources = new Map([["images/
|
|
80
|
-
const blob = await createOBZ(boards, "board-1", resources);
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
### Load either format from one input
|
|
84
|
-
|
|
85
|
-
```ts
|
|
86
|
-
import { loadBoard } from "@shayc/open-board-format";
|
|
87
|
-
|
|
88
|
-
// `file` came from a drag-and-drop or <input type="file"> — could be .obf or .obz
|
|
89
|
-
const loaded = await loadBoard(file);
|
|
86
|
+
const resources = new Map([["images/hello.png", pngBytes]]);
|
|
90
87
|
|
|
91
|
-
|
|
92
|
-
const homeBoard = loaded.archive.boards.get("1");
|
|
93
|
-
} else {
|
|
94
|
-
const board = loaded.board;
|
|
95
|
-
}
|
|
88
|
+
const blob = await createOBZ([board], "board-1", resources);
|
|
96
89
|
```
|
|
97
90
|
|
|
91
|
+
The `manifest.json` is generated for you — boards are written to `boards/<id>.obf`, and `rootBoardId` (the second argument) selects the home board.
|
|
92
|
+
|
|
98
93
|
### Validate with Zod directly
|
|
99
94
|
|
|
100
95
|
```ts
|
|
@@ -111,6 +106,8 @@ if (result.success) {
|
|
|
111
106
|
|
|
112
107
|
## API
|
|
113
108
|
|
|
109
|
+
One naming convention covers the whole surface: `parse*` takes a JSON string, `validate*` takes an already-parsed object, `load*` takes a browser `File` (`loadBoard` also accepts an `ArrayBuffer`), `stringify*` returns a JSON string — and `extractOBZ`/`createOBZ` operate on whole archives.
|
|
110
|
+
|
|
114
111
|
### OBF (single board)
|
|
115
112
|
|
|
116
113
|
| Function | Description |
|
|
@@ -122,14 +119,14 @@ if (result.success) {
|
|
|
122
119
|
|
|
123
120
|
### OBZ (board package)
|
|
124
121
|
|
|
125
|
-
| Function | Description
|
|
126
|
-
| -------------------------------------------- |
|
|
127
|
-
| `loadOBZ(file)` | Load an OBZ package from a browser `File`
|
|
128
|
-
| `extractOBZ(archive)` | Extract boards, manifest, and resources from an `ArrayBuffer` |
|
|
129
|
-
| `createOBZ(boards, rootBoardId, resources?)` | Create an OBZ package as a `Blob`
|
|
130
|
-
| `parseManifest(json)` | Parse a `manifest.json` string into a validated `OBFManifest`
|
|
122
|
+
| Function | Description |
|
|
123
|
+
| -------------------------------------------- | ------------------------------------------------------------------------- |
|
|
124
|
+
| `loadOBZ(file)` | Load an OBZ package from a browser `File` |
|
|
125
|
+
| `extractOBZ(archive)` | Extract boards, manifest, root board, and resources from an `ArrayBuffer` |
|
|
126
|
+
| `createOBZ(boards, rootBoardId, resources?)` | Create an OBZ package as a `Blob` |
|
|
127
|
+
| `parseManifest(json)` | Parse a `manifest.json` string into a validated `OBFManifest` |
|
|
131
128
|
|
|
132
|
-
###
|
|
129
|
+
### Format detection
|
|
133
130
|
|
|
134
131
|
| Function | Description |
|
|
135
132
|
| ------------------ | ------------------------------------------------------------------------------------------- |
|
|
@@ -145,28 +142,28 @@ if (result.success) {
|
|
|
145
142
|
|
|
146
143
|
### Types
|
|
147
144
|
|
|
148
|
-
| Type | Description
|
|
149
|
-
| --------------------- |
|
|
150
|
-
| `OBFBoard` | A single communication board
|
|
151
|
-
| `OBFGrid` | Grid layout (rows, columns, order)
|
|
152
|
-
| `OBFButton` | A button on the board
|
|
153
|
-
| `OBFButtonAction` | Button action (spelling or specialty)
|
|
154
|
-
| `OBFSpellingAction` | Spelling action (e.g., `+s`)
|
|
155
|
-
| `OBFSpecialtyAction` | Specialty action (e.g., `:clear`)
|
|
156
|
-
| `OBFLoadBoard` | Reference to load another board
|
|
157
|
-
| `OBFMedia` | Common media properties (base for `OBFImage` and `OBFSound`)
|
|
158
|
-
| `OBFImage` | An image resource (extends `OBFMedia`)
|
|
159
|
-
| `OBFSound` | A sound resource (
|
|
160
|
-
| `OBFSymbolInfo` | Symbol set reference
|
|
161
|
-
| `OBFManifest` | OBZ package manifest
|
|
162
|
-
| `ParsedOBZ` | Return type of `extractOBZ` / `loadOBZ` — `{ manifest, boards, resources }`
|
|
163
|
-
| `LoadedBoard` | Return type of `loadBoard` — `{ format: "obz", archive } \| { format: "obf", board }`
|
|
164
|
-
| `OBFID` | Unique identifier (string, coerced from number)
|
|
165
|
-
| `OBFFormatVersion` | Format version string (e.g., `open-board-0.1`)
|
|
166
|
-
| `OBFLicense` | Licensing information
|
|
167
|
-
| `OBFLocaleCode` | BCP 47 locale code
|
|
168
|
-
| `OBFLocalizedStrings` | Key-value string translations
|
|
169
|
-
| `OBFStrings` | Multi-locale string translations
|
|
145
|
+
| Type | Description |
|
|
146
|
+
| --------------------- | -------------------------------------------------------------------------------------- |
|
|
147
|
+
| `OBFBoard` | A single communication board |
|
|
148
|
+
| `OBFGrid` | Grid layout (rows, columns, order) |
|
|
149
|
+
| `OBFButton` | A button on the board |
|
|
150
|
+
| `OBFButtonAction` | Button action (spelling or specialty) |
|
|
151
|
+
| `OBFSpellingAction` | Spelling action (e.g., `+s`) |
|
|
152
|
+
| `OBFSpecialtyAction` | Specialty action (e.g., `:clear`) |
|
|
153
|
+
| `OBFLoadBoard` | Reference to load another board |
|
|
154
|
+
| `OBFMedia` | Common media properties (base for `OBFImage` and `OBFSound`) |
|
|
155
|
+
| `OBFImage` | An image resource (extends `OBFMedia`) |
|
|
156
|
+
| `OBFSound` | A sound resource (alias of `OBFMedia`) |
|
|
157
|
+
| `OBFSymbolInfo` | Symbol set reference |
|
|
158
|
+
| `OBFManifest` | OBZ package manifest |
|
|
159
|
+
| `ParsedOBZ` | Return type of `extractOBZ` / `loadOBZ` — `{ manifest, boards, rootBoard, resources }` |
|
|
160
|
+
| `LoadedBoard` | Return type of `loadBoard` — `{ format: "obz", archive } \| { format: "obf", board }` |
|
|
161
|
+
| `OBFID` | Unique identifier (string, coerced from number) |
|
|
162
|
+
| `OBFFormatVersion` | Format version string (e.g., `open-board-0.1`) |
|
|
163
|
+
| `OBFLicense` | Licensing information |
|
|
164
|
+
| `OBFLocaleCode` | BCP 47 locale code |
|
|
165
|
+
| `OBFLocalizedStrings` | Key-value string translations |
|
|
166
|
+
| `OBFStrings` | Multi-locale string translations |
|
|
170
167
|
|
|
171
168
|
### Schemas
|
|
172
169
|
|
|
@@ -181,8 +178,11 @@ import { OBFButtonSchema, OBFManifestSchema } from "@shayc/open-board-format";
|
|
|
181
178
|
All failures throw plain `Error`. The message identifies what failed, typically with one of these prefixes:
|
|
182
179
|
|
|
183
180
|
- `Invalid OBF:` — schema validation rejected an OBF board.
|
|
184
|
-
- `Invalid OBZ:` — the package was rejected.
|
|
181
|
+
- `Invalid OBZ:` — the package was rejected.
|
|
182
|
+
- On read: not a ZIP, missing manifest, the manifest references a board file not in the archive, or a board's `id` differs from the ID the manifest declares for it.
|
|
183
|
+
- On write (`createOBZ`): `rootBoardId` matches no board, two boards share the same `id`, a board fails validation, two boards map the same media `id` to conflicting paths, a declared image/sound `path` has no matching resource, or a resource would overwrite a generated entry.
|
|
185
184
|
- `Invalid manifest:` — `manifest.json` failed to parse or validate, including a `root` that is not listed in `paths.boards`.
|
|
185
|
+
- `Failed to unzip:` / `Failed to zip:` — the archive could not be decompressed (truncated or corrupt despite a valid ZIP signature) or compressed.
|
|
186
186
|
|
|
187
187
|
When the root cause is a `JSON.parse` failure, the original error is preserved as `error.cause`. For finer-grained validation, drop one level down and use the Zod schemas directly with `safeParse` — the `issues` array tells you exactly which field failed.
|
|
188
188
|
|
|
@@ -192,9 +192,17 @@ OBZ archives are untrusted input. This library does not enforce limits on entry
|
|
|
192
192
|
|
|
193
193
|
Found a security issue? Open a private advisory at [github.com/shayc/open-board-format/security/advisories/new](https://github.com/shayc/open-board-format/security/advisories/new).
|
|
194
194
|
|
|
195
|
+
## Scope
|
|
196
|
+
|
|
197
|
+
What this library deliberately does not do:
|
|
198
|
+
|
|
199
|
+
- **No network I/O** — media referenced by `url` or `data_url` is not fetched; resolving external media is up to you.
|
|
200
|
+
- **No rendering** — it parses and validates data; drawing boards and playing sounds belong to your app.
|
|
201
|
+
- **No extraction limits or path sanitization** — see [Security](#security) before writing archive contents to disk.
|
|
202
|
+
|
|
195
203
|
## Versioning
|
|
196
204
|
|
|
197
|
-
Semver
|
|
205
|
+
Semver. Pre-1.0: minor releases may contain breaking changes — see [CHANGELOG.md](CHANGELOG.md).
|
|
198
206
|
|
|
199
207
|
## Contributing
|
|
200
208
|
|
|
@@ -202,7 +210,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup (Node 22+, Vitest,
|
|
|
202
210
|
|
|
203
211
|
## Related
|
|
204
212
|
|
|
205
|
-
- [Open Board Format specification](https://www.openboardformat.org/docs) — the official standard and format documentation.
|
|
213
|
+
- [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.
|
|
206
214
|
- [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)).
|
|
207
215
|
|
|
208
216
|
## License
|
package/dist/index.d.mts
CHANGED
|
@@ -1,62 +1,78 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
//#region src/schema.d.ts
|
|
4
|
-
/**
|
|
5
|
-
* Open Board Format (OBF) Zod Schemas
|
|
6
|
-
*
|
|
7
|
-
* These schemas represent the Open Board Format, designed for sharing communication boards and board sets
|
|
8
|
-
* between Augmentative and Alternative Communication (AAC) applications.
|
|
9
|
-
*
|
|
10
|
-
* Official OBF specification: https://www.openboardformat.org/docs
|
|
11
|
-
*
|
|
12
|
-
* @author Shay Cojocaru
|
|
13
|
-
* @license MIT
|
|
14
|
-
*/
|
|
15
4
|
/** Unique board-element identifier, coerced to a non-empty string. */
|
|
16
5
|
declare const OBFIDSchema: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
|
|
17
|
-
type OBFID = z.infer<typeof OBFIDSchema>;
|
|
18
6
|
/**
|
|
19
|
-
*
|
|
7
|
+
* Unique board-element identifier, coerced to a non-empty string.
|
|
8
|
+
* See {@link OBFIDSchema}.
|
|
20
9
|
*/
|
|
10
|
+
type OBFID = z.infer<typeof OBFIDSchema>;
|
|
11
|
+
/** Format version of the Open Board Format, e.g., `open-board-0.1`. */
|
|
21
12
|
declare const OBFFormatVersionSchema: z.ZodString;
|
|
13
|
+
/**
|
|
14
|
+
* Format version of the Open Board Format, e.g., `open-board-0.1`.
|
|
15
|
+
* See {@link OBFFormatVersionSchema}.
|
|
16
|
+
*/
|
|
22
17
|
type OBFFormatVersion = z.infer<typeof OBFFormatVersionSchema>;
|
|
23
18
|
/**
|
|
24
|
-
* Locale identifier, typically a BCP 47 language tag (e.g.,
|
|
25
|
-
* Not strictly validated — any string is accepted.
|
|
19
|
+
* Locale identifier, typically a BCP 47 language tag (e.g., `en`, `en-US`,
|
|
20
|
+
* `fr-CA`). Not strictly validated — any string is accepted.
|
|
26
21
|
*/
|
|
27
22
|
declare const OBFLocaleCodeSchema: z.ZodString;
|
|
23
|
+
/**
|
|
24
|
+
* Locale identifier, typically a BCP 47 language tag, e.g., `en`, `en-US`.
|
|
25
|
+
* See {@link OBFLocaleCodeSchema}.
|
|
26
|
+
*/
|
|
28
27
|
type OBFLocaleCode = z.infer<typeof OBFLocaleCodeSchema>;
|
|
29
28
|
/**
|
|
30
|
-
*
|
|
29
|
+
* Translations for a single locale, keyed by the source string,
|
|
30
|
+
* e.g., `{ "hello": "hola" }`.
|
|
31
31
|
*/
|
|
32
32
|
declare const OBFLocalizedStringsSchema: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
33
|
+
/**
|
|
34
|
+
* Translations for a single locale, keyed by the source string.
|
|
35
|
+
* See {@link OBFLocalizedStringsSchema}.
|
|
36
|
+
*/
|
|
33
37
|
type OBFLocalizedStrings = z.infer<typeof OBFLocalizedStringsSchema>;
|
|
34
38
|
/**
|
|
35
39
|
* Locale-keyed dictionary of translated strings,
|
|
36
40
|
* e.g., `{ en: { greeting: "Hello" }, fr: { greeting: "Bonjour" } }`.
|
|
37
41
|
*/
|
|
38
42
|
declare const OBFStringsSchema: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
43
|
+
/**
|
|
44
|
+
* Locale-keyed dictionary of translated strings.
|
|
45
|
+
* See {@link OBFStringsSchema}.
|
|
46
|
+
*/
|
|
39
47
|
type OBFStrings = z.infer<typeof OBFStringsSchema>;
|
|
40
48
|
/**
|
|
41
49
|
* Spelling action: a `+` prefix followed by the text to append,
|
|
42
|
-
* e.g.,
|
|
50
|
+
* e.g., `+hello`.
|
|
43
51
|
*/
|
|
44
52
|
declare const OBFSpellingActionSchema: z.ZodString;
|
|
53
|
+
/**
|
|
54
|
+
* Spelling action: a `+` prefix followed by the text to append, e.g., `+hello`.
|
|
55
|
+
* See {@link OBFSpellingActionSchema}.
|
|
56
|
+
*/
|
|
45
57
|
type OBFSpellingAction = z.infer<typeof OBFSpellingActionSchema>;
|
|
46
58
|
/**
|
|
47
|
-
* Specialty action prefixed with `:`, e.g.,
|
|
59
|
+
* Specialty action prefixed with `:`, e.g., `:clear`.
|
|
48
60
|
* Custom extensions use the `:ext_` prefix.
|
|
49
61
|
*/
|
|
50
62
|
declare const OBFSpecialtyActionSchema: z.ZodString;
|
|
51
|
-
type OBFSpecialtyAction = z.infer<typeof OBFSpecialtyActionSchema>;
|
|
52
63
|
/**
|
|
53
|
-
*
|
|
64
|
+
* Specialty action prefixed with `:`, e.g., `:clear`.
|
|
65
|
+
* See {@link OBFSpecialtyActionSchema}.
|
|
54
66
|
*/
|
|
67
|
+
type OBFSpecialtyAction = z.infer<typeof OBFSpecialtyActionSchema>;
|
|
68
|
+
/** Union of spelling and specialty actions that a button can trigger. */
|
|
55
69
|
declare const OBFButtonActionSchema: z.ZodUnion<readonly [z.ZodString, z.ZodString]>;
|
|
56
|
-
type OBFButtonAction = z.infer<typeof OBFButtonActionSchema>;
|
|
57
70
|
/**
|
|
58
|
-
*
|
|
71
|
+
* Union of spelling and specialty actions that a button can trigger.
|
|
72
|
+
* See {@link OBFButtonActionSchema}.
|
|
59
73
|
*/
|
|
74
|
+
type OBFButtonAction = z.infer<typeof OBFButtonActionSchema>;
|
|
75
|
+
/** License terms and attribution for a resource. */
|
|
60
76
|
declare const OBFLicenseSchema: z.ZodObject<{
|
|
61
77
|
type: z.ZodString;
|
|
62
78
|
copyright_notice_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
@@ -65,14 +81,22 @@ declare const OBFLicenseSchema: z.ZodObject<{
|
|
|
65
81
|
author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
66
82
|
author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
67
83
|
}, z.core.$loose>;
|
|
84
|
+
/**
|
|
85
|
+
* License terms and attribution for a resource.
|
|
86
|
+
* See {@link OBFLicenseSchema}.
|
|
87
|
+
*/
|
|
68
88
|
type OBFLicense = z.infer<typeof OBFLicenseSchema>;
|
|
69
89
|
/**
|
|
70
90
|
* Common properties for media resources (images and sounds).
|
|
71
91
|
*
|
|
72
92
|
* When multiple references are provided, they should be used in the following order:
|
|
73
|
-
* 1. data
|
|
74
|
-
* 2. path
|
|
75
|
-
* 3. url
|
|
93
|
+
* 1. `data`
|
|
94
|
+
* 2. `path`
|
|
95
|
+
* 3. `url`
|
|
96
|
+
*
|
|
97
|
+
* `data_url` is not part of this fallback chain — it is an API endpoint for
|
|
98
|
+
* retrieving information about the resource, not an alternative source of
|
|
99
|
+
* the media bytes.
|
|
76
100
|
*/
|
|
77
101
|
declare const OBFMediaSchema: z.ZodObject<{
|
|
78
102
|
id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
|
|
@@ -90,14 +114,20 @@ declare const OBFMediaSchema: z.ZodObject<{
|
|
|
90
114
|
author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
91
115
|
}, z.core.$loose>>;
|
|
92
116
|
}, z.core.$loose>;
|
|
93
|
-
type OBFMedia = z.infer<typeof OBFMediaSchema>;
|
|
94
117
|
/**
|
|
95
|
-
*
|
|
118
|
+
* Common properties for media resources (images and sounds).
|
|
119
|
+
* See {@link OBFMediaSchema}.
|
|
96
120
|
*/
|
|
121
|
+
type OBFMedia = z.infer<typeof OBFMediaSchema>;
|
|
122
|
+
/** Reference to a symbol in a proprietary symbol set (e.g., SymbolStix). */
|
|
97
123
|
declare const OBFSymbolInfoSchema: z.ZodObject<{
|
|
98
124
|
set: z.ZodString;
|
|
99
125
|
filename: z.ZodString;
|
|
100
126
|
}, z.core.$loose>;
|
|
127
|
+
/**
|
|
128
|
+
* Reference to a symbol in a proprietary symbol set.
|
|
129
|
+
* See {@link OBFSymbolInfoSchema}.
|
|
130
|
+
*/
|
|
101
131
|
type OBFSymbolInfo = z.infer<typeof OBFSymbolInfoSchema>;
|
|
102
132
|
/**
|
|
103
133
|
* Image resource, extending {@link OBFMediaSchema} with optional
|
|
@@ -131,6 +161,10 @@ declare const OBFImageSchema: z.ZodObject<{
|
|
|
131
161
|
width: z.ZodOptional<z.ZodNumber>;
|
|
132
162
|
height: z.ZodOptional<z.ZodNumber>;
|
|
133
163
|
}, z.core.$loose>;
|
|
164
|
+
/**
|
|
165
|
+
* Image resource with optional symbol and dimension properties.
|
|
166
|
+
* See {@link OBFImageSchema}.
|
|
167
|
+
*/
|
|
134
168
|
type OBFImage = z.infer<typeof OBFImageSchema>;
|
|
135
169
|
/**
|
|
136
170
|
* Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.
|
|
@@ -151,10 +185,12 @@ declare const OBFSoundSchema: z.ZodObject<{
|
|
|
151
185
|
author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
152
186
|
}, z.core.$loose>>;
|
|
153
187
|
}, z.core.$loose>;
|
|
154
|
-
type OBFSound = z.infer<typeof OBFSoundSchema>;
|
|
155
188
|
/**
|
|
156
|
-
*
|
|
189
|
+
* Audio resource, identical to {@link OBFMedia}.
|
|
190
|
+
* See {@link OBFSoundSchema}.
|
|
157
191
|
*/
|
|
192
|
+
type OBFSound = z.infer<typeof OBFSoundSchema>;
|
|
193
|
+
/** Reference to another board, resolved by ID, path, or URL. */
|
|
158
194
|
declare const OBFLoadBoardSchema: z.ZodObject<{
|
|
159
195
|
id: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string | undefined, string | number>>>;
|
|
160
196
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -162,6 +198,10 @@ declare const OBFLoadBoardSchema: z.ZodObject<{
|
|
|
162
198
|
url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
163
199
|
path: z.ZodOptional<z.ZodString>;
|
|
164
200
|
}, z.core.$loose>;
|
|
201
|
+
/**
|
|
202
|
+
* Reference to another board, resolved by ID, path, or URL.
|
|
203
|
+
* See {@link OBFLoadBoardSchema}.
|
|
204
|
+
*/
|
|
165
205
|
type OBFLoadBoard = z.infer<typeof OBFLoadBoardSchema>;
|
|
166
206
|
/**
|
|
167
207
|
* Interactive element on a board, optionally linked to images, sounds, and actions.
|
|
@@ -188,6 +228,10 @@ declare const OBFButtonSchema: z.ZodObject<{
|
|
|
188
228
|
width: z.ZodOptional<z.ZodNumber>;
|
|
189
229
|
height: z.ZodOptional<z.ZodNumber>;
|
|
190
230
|
}, z.core.$loose>;
|
|
231
|
+
/**
|
|
232
|
+
* Interactive element on a board, optionally linked to images, sounds, and
|
|
233
|
+
* actions. See {@link OBFButtonSchema}.
|
|
234
|
+
*/
|
|
191
235
|
type OBFButton = z.infer<typeof OBFButtonSchema>;
|
|
192
236
|
/**
|
|
193
237
|
* Row-and-column layout that arranges buttons by their IDs.
|
|
@@ -197,6 +241,10 @@ declare const OBFGridSchema: z.ZodObject<{
|
|
|
197
241
|
columns: z.ZodNumber;
|
|
198
242
|
order: z.ZodArray<z.ZodArray<z.ZodUnion<readonly [z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>, z.ZodNull]>>>;
|
|
199
243
|
}, z.core.$loose>;
|
|
244
|
+
/**
|
|
245
|
+
* Row-and-column layout that arranges buttons by their IDs.
|
|
246
|
+
* See {@link OBFGridSchema}.
|
|
247
|
+
*/
|
|
200
248
|
type OBFGrid = z.infer<typeof OBFGridSchema>;
|
|
201
249
|
/**
|
|
202
250
|
* Root object of an `.obf` file: the complete definition of a single communication board.
|
|
@@ -283,6 +331,10 @@ declare const OBFBoardSchema: z.ZodObject<{
|
|
|
283
331
|
}, z.core.$loose>>;
|
|
284
332
|
strings: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>>>;
|
|
285
333
|
}, z.core.$loose>;
|
|
334
|
+
/**
|
|
335
|
+
* The complete definition of a single communication board — root object of
|
|
336
|
+
* an `.obf` file. See {@link OBFBoardSchema}.
|
|
337
|
+
*/
|
|
286
338
|
type OBFBoard = z.infer<typeof OBFBoardSchema>;
|
|
287
339
|
/**
|
|
288
340
|
* Table of contents for an `.obz` package, mapping resource IDs to their archive paths.
|
|
@@ -296,6 +348,10 @@ declare const OBFManifestSchema: z.ZodObject<{
|
|
|
296
348
|
sounds: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
297
349
|
}, z.core.$loose>;
|
|
298
350
|
}, z.core.$loose>;
|
|
351
|
+
/**
|
|
352
|
+
* Table of contents for an `.obz` package, mapping resource IDs to their
|
|
353
|
+
* archive paths. See {@link OBFManifestSchema}.
|
|
354
|
+
*/
|
|
299
355
|
type OBFManifest = z.infer<typeof OBFManifestSchema>;
|
|
300
356
|
//#endregion
|
|
301
357
|
//#region src/obf.d.ts
|
|
@@ -308,7 +364,7 @@ type OBFManifest = z.infer<typeof OBFManifestSchema>;
|
|
|
308
364
|
* @param json - The JSON string to parse.
|
|
309
365
|
* @returns The validated board object.
|
|
310
366
|
*
|
|
311
|
-
* @throws {Error} If the JSON is malformed or
|
|
367
|
+
* @throws {Error} If the JSON is malformed or fails schema validation.
|
|
312
368
|
*/
|
|
313
369
|
declare function parseOBF(json: string): OBFBoard;
|
|
314
370
|
/**
|
|
@@ -329,7 +385,7 @@ declare function loadOBF(file: File): Promise<OBFBoard>;
|
|
|
329
385
|
* @param data - The value to validate.
|
|
330
386
|
* @returns The validated board object.
|
|
331
387
|
*
|
|
332
|
-
* @throws {Error} If the value
|
|
388
|
+
* @throws {Error} If the value fails schema validation.
|
|
333
389
|
*/
|
|
334
390
|
declare function validateOBF(data: unknown): OBFBoard;
|
|
335
391
|
/**
|
|
@@ -343,16 +399,22 @@ declare function stringifyOBF(board: OBFBoard): string;
|
|
|
343
399
|
//#region src/obz.d.ts
|
|
344
400
|
/**
|
|
345
401
|
* Fully extracted contents of an `.obz` archive.
|
|
346
|
-
*
|
|
347
|
-
* @property manifest - The package table of contents.
|
|
348
|
-
* @property boards - Board ID → validated board object.
|
|
349
|
-
* @property resources - Archive path → raw bytes for every entry in the archive,
|
|
350
|
-
* including `manifest.json` and the `.obf` boards as well as
|
|
351
|
-
* media such as images and sounds.
|
|
352
402
|
*/
|
|
353
403
|
interface ParsedOBZ {
|
|
404
|
+
/** The package's table of contents. */
|
|
354
405
|
manifest: OBFManifest;
|
|
406
|
+
/** Validated board objects keyed by board ID. */
|
|
355
407
|
boards: Map<string, OBFBoard>;
|
|
408
|
+
/**
|
|
409
|
+
* The package's entry-point board — the one `manifest.root` points at,
|
|
410
|
+
* already resolved. Same object as `boards.get(rootBoard.id)`.
|
|
411
|
+
*/
|
|
412
|
+
rootBoard: OBFBoard;
|
|
413
|
+
/**
|
|
414
|
+
* Raw bytes for every entry in the archive, keyed by archive path —
|
|
415
|
+
* including `manifest.json` and the `.obf` boards as well as media
|
|
416
|
+
* such as images and sounds.
|
|
417
|
+
*/
|
|
356
418
|
resources: Map<string, Uint8Array>;
|
|
357
419
|
}
|
|
358
420
|
/**
|
|
@@ -362,9 +424,9 @@ interface ParsedOBZ {
|
|
|
362
424
|
* read the file to an `ArrayBuffer` and pass it to {@link extractOBZ} instead.
|
|
363
425
|
*
|
|
364
426
|
* @param file - A `File` handle pointing to an `.obz` archive.
|
|
365
|
-
* @returns The parsed manifest, boards, and binary resources.
|
|
427
|
+
* @returns The parsed manifest, boards, root board, and binary resources.
|
|
366
428
|
*
|
|
367
|
-
* @throws {Error}
|
|
429
|
+
* @throws {Error} Same failures as {@link extractOBZ}, which this delegates to.
|
|
368
430
|
*/
|
|
369
431
|
declare function loadOBZ(file: File): Promise<ParsedOBZ>;
|
|
370
432
|
/**
|
|
@@ -372,9 +434,12 @@ declare function loadOBZ(file: File): Promise<ParsedOBZ>;
|
|
|
372
434
|
*
|
|
373
435
|
* @param archive - The OBZ archive as an `ArrayBuffer`.
|
|
374
436
|
* @returns The parsed manifest, a map of board IDs to validated boards,
|
|
375
|
-
* and a map of file paths to their
|
|
437
|
+
* the resolved root board, and a map of file paths to their
|
|
438
|
+
* binary content.
|
|
376
439
|
*
|
|
377
|
-
* @throws {Error} If the archive is not a valid ZIP
|
|
440
|
+
* @throws {Error} If the archive is not a valid ZIP, the manifest is missing,
|
|
441
|
+
* a board declared in the manifest is missing or fails validation, or a
|
|
442
|
+
* board's `id` differs from the ID the manifest declares for it.
|
|
378
443
|
*/
|
|
379
444
|
declare function extractOBZ(archive: ArrayBuffer): Promise<ParsedOBZ>;
|
|
380
445
|
/**
|
|
@@ -399,9 +464,9 @@ declare function parseManifest(json: string): OBFManifest;
|
|
|
399
464
|
* @returns A `Blob` containing the compressed OBZ archive.
|
|
400
465
|
*
|
|
401
466
|
* @throws {Error} If `rootBoardId` does not match any of the supplied boards.
|
|
402
|
-
* @throws {Error} If two supplied boards share the same
|
|
467
|
+
* @throws {Error} If two supplied boards share the same ID.
|
|
403
468
|
* @throws {Error} If a supplied board fails schema validation.
|
|
404
|
-
* @throws {Error} If two boards declare the same media
|
|
469
|
+
* @throws {Error} If two boards declare the same media ID with conflicting paths.
|
|
405
470
|
* @throws {Error} If a board declares an image or sound `path` with no matching entry in `resources`.
|
|
406
471
|
* @throws {Error} If a `resources` entry would overwrite the generated `manifest.json` or a board file.
|
|
407
472
|
*/
|
|
@@ -417,9 +482,9 @@ declare function createOBZ(boards: OBFBoard[], rootBoardId: string, resources?:
|
|
|
417
482
|
* ```ts
|
|
418
483
|
* const loaded = await loadBoard(file);
|
|
419
484
|
* if (loaded.format === "obz") {
|
|
420
|
-
* loaded.archive.
|
|
485
|
+
* loaded.archive.rootBoard; // home board of the ParsedOBZ archive
|
|
421
486
|
* } else {
|
|
422
|
-
* loaded.board;
|
|
487
|
+
* loaded.board; // OBFBoard
|
|
423
488
|
* }
|
|
424
489
|
* ```
|
|
425
490
|
*/
|
|
@@ -434,11 +499,11 @@ type LoadedBoard = {
|
|
|
434
499
|
* Detect whether the input is a single OBF board or an OBZ package and load it
|
|
435
500
|
* accordingly.
|
|
436
501
|
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
502
|
+
* Input that begins with the ZIP magic prefix is treated as an `.obz` package;
|
|
503
|
+
* anything else is parsed as an `.obf` board. The input is read once, so
|
|
504
|
+
* consumers can accept either format from a single drag-and-drop, file picker,
|
|
505
|
+
* or fetch response without inspecting the file extension or re-deriving the
|
|
506
|
+
* OBF-vs-OBZ distinction themselves.
|
|
442
507
|
*
|
|
443
508
|
* @param input - A `File` handle or `ArrayBuffer` holding `.obf` or `.obz` content.
|
|
444
509
|
* @returns A discriminated union tagged by `format`.
|
|
@@ -449,13 +514,16 @@ type LoadedBoard = {
|
|
|
449
514
|
declare function loadBoard(input: File | ArrayBuffer): Promise<LoadedBoard>;
|
|
450
515
|
//#endregion
|
|
451
516
|
//#region src/zip.d.ts
|
|
517
|
+
/**
|
|
518
|
+
* Minimal ZIP helpers over fflate: signature sniffing, unzip, and zip.
|
|
519
|
+
*/
|
|
452
520
|
/**
|
|
453
521
|
* Decompress a ZIP archive into a map of file paths to raw bytes.
|
|
454
522
|
*
|
|
455
523
|
* @param archive - The ZIP archive as an `ArrayBuffer`.
|
|
456
524
|
* @returns A map of file paths to their decompressed content.
|
|
457
525
|
*
|
|
458
|
-
* @throws {Error} If
|
|
526
|
+
* @throws {Error} If the archive is corrupt or cannot be decompressed.
|
|
459
527
|
*/
|
|
460
528
|
declare function unzip(archive: ArrayBuffer): Promise<Map<string, Uint8Array>>;
|
|
461
529
|
/**
|
|
@@ -468,7 +536,7 @@ declare function unzip(archive: ArrayBuffer): Promise<Map<string, Uint8Array>>;
|
|
|
468
536
|
* @param entries - A map of file paths to their content bytes.
|
|
469
537
|
* @returns The compressed archive as a `Uint8Array`.
|
|
470
538
|
*
|
|
471
|
-
* @throws {Error} If
|
|
539
|
+
* @throws {Error} If fflate fails to compress an entry.
|
|
472
540
|
*/
|
|
473
541
|
declare function zip(entries: Map<string, Uint8Array | ArrayBuffer>): Promise<Uint8Array>;
|
|
474
542
|
/**
|
package/dist/index.mjs
CHANGED
|
@@ -8,9 +8,6 @@ import { unzip as unzip$1, zip as zip$1 } from "fflate";
|
|
|
8
8
|
* between Augmentative and Alternative Communication (AAC) applications.
|
|
9
9
|
*
|
|
10
10
|
* Official OBF specification: https://www.openboardformat.org/docs
|
|
11
|
-
*
|
|
12
|
-
* @author Shay Cojocaru
|
|
13
|
-
* @license MIT
|
|
14
11
|
*/
|
|
15
12
|
/** Optional URL that treats empty strings as undefined. */
|
|
16
13
|
const OBFOptionalUrlSchema = z.union([z.url(), z.literal("")]).transform((val) => val === "" ? void 0 : val).optional();
|
|
@@ -23,17 +20,16 @@ const OBFOptionalIDSchema = z.union([z.string(), z.number()]).transform((val) =>
|
|
|
23
20
|
}).optional();
|
|
24
21
|
/** Unique board-element identifier, coerced to a non-empty string. */
|
|
25
22
|
const OBFIDSchema = z.union([z.string(), z.number()]).transform((val) => String(val)).pipe(z.string().min(1));
|
|
26
|
-
/**
|
|
27
|
-
* Format version of the Open Board Format, e.g., 'open-board-0.1'.
|
|
28
|
-
*/
|
|
23
|
+
/** Format version of the Open Board Format, e.g., `open-board-0.1`. */
|
|
29
24
|
const OBFFormatVersionSchema = z.string().regex(/^open-board-.+$/);
|
|
30
25
|
/**
|
|
31
|
-
* Locale identifier, typically a BCP 47 language tag (e.g.,
|
|
32
|
-
* Not strictly validated — any string is accepted.
|
|
26
|
+
* Locale identifier, typically a BCP 47 language tag (e.g., `en`, `en-US`,
|
|
27
|
+
* `fr-CA`). Not strictly validated — any string is accepted.
|
|
33
28
|
*/
|
|
34
29
|
const OBFLocaleCodeSchema = z.string();
|
|
35
30
|
/**
|
|
36
|
-
*
|
|
31
|
+
* Translations for a single locale, keyed by the source string,
|
|
32
|
+
* e.g., `{ "hello": "hola" }`.
|
|
37
33
|
*/
|
|
38
34
|
const OBFLocalizedStringsSchema = z.record(z.string(), z.string());
|
|
39
35
|
/**
|
|
@@ -43,23 +39,19 @@ const OBFLocalizedStringsSchema = z.record(z.string(), z.string());
|
|
|
43
39
|
const OBFStringsSchema = z.record(z.string(), OBFLocalizedStringsSchema);
|
|
44
40
|
/**
|
|
45
41
|
* Spelling action: a `+` prefix followed by the text to append,
|
|
46
|
-
* e.g.,
|
|
42
|
+
* e.g., `+hello`.
|
|
47
43
|
*/
|
|
48
44
|
const OBFSpellingActionSchema = z.string().regex(/^\+.+$/);
|
|
49
45
|
/**
|
|
50
|
-
* Specialty action prefixed with `:`, e.g.,
|
|
46
|
+
* Specialty action prefixed with `:`, e.g., `:clear`.
|
|
51
47
|
* Custom extensions use the `:ext_` prefix.
|
|
52
48
|
*/
|
|
53
49
|
const OBFSpecialtyActionSchema = z.string().regex(/^:[a-z][a-z0-9_-]*$/i);
|
|
54
|
-
/**
|
|
55
|
-
* Union of spelling and specialty actions that a button can trigger.
|
|
56
|
-
*/
|
|
50
|
+
/** Union of spelling and specialty actions that a button can trigger. */
|
|
57
51
|
const OBFButtonActionSchema = z.union([OBFSpellingActionSchema, OBFSpecialtyActionSchema]);
|
|
58
|
-
/**
|
|
59
|
-
* License terms and attribution for a resource.
|
|
60
|
-
*/
|
|
52
|
+
/** License terms and attribution for a resource. */
|
|
61
53
|
const OBFLicenseSchema = z.looseObject({
|
|
62
|
-
/** Type of the license, e.g.,
|
|
54
|
+
/** Type of the license, e.g., `CC-BY-SA`. */
|
|
63
55
|
type: z.string(),
|
|
64
56
|
/** URL to the license terms. */
|
|
65
57
|
copyright_notice_url: OBFOptionalUrlSchema,
|
|
@@ -76,31 +68,36 @@ const OBFLicenseSchema = z.looseObject({
|
|
|
76
68
|
* Common properties for media resources (images and sounds).
|
|
77
69
|
*
|
|
78
70
|
* When multiple references are provided, they should be used in the following order:
|
|
79
|
-
* 1. data
|
|
80
|
-
* 2. path
|
|
81
|
-
* 3. url
|
|
71
|
+
* 1. `data`
|
|
72
|
+
* 2. `path`
|
|
73
|
+
* 3. `url`
|
|
74
|
+
*
|
|
75
|
+
* `data_url` is not part of this fallback chain — it is an API endpoint for
|
|
76
|
+
* retrieving information about the resource, not an alternative source of
|
|
77
|
+
* the media bytes.
|
|
82
78
|
*/
|
|
83
79
|
const OBFMediaSchema = z.looseObject({
|
|
84
80
|
/** Unique identifier for the media resource. */
|
|
85
81
|
id: OBFIDSchema,
|
|
86
|
-
/**
|
|
82
|
+
/** Media data inlined as a `data:` URI. */
|
|
87
83
|
data: z.string().optional(),
|
|
88
|
-
/** Path to the media file within an
|
|
84
|
+
/** Path to the media file within an `.obz` package. */
|
|
89
85
|
path: z.string().optional(),
|
|
90
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* URL of an API endpoint for fetching the media programmatically —
|
|
88
|
+
* not a `data:` URI (that is `data`).
|
|
89
|
+
*/
|
|
91
90
|
data_url: OBFOptionalUrlSchema,
|
|
92
91
|
/** URL to the media resource. */
|
|
93
92
|
url: OBFOptionalUrlSchema,
|
|
94
|
-
/** MIME type of the media, e.g.,
|
|
93
|
+
/** MIME type of the media, e.g., `image/png`, `audio/mpeg`. */
|
|
95
94
|
content_type: z.string().optional(),
|
|
96
95
|
/** Licensing information for the media. */
|
|
97
96
|
license: OBFLicenseSchema.optional()
|
|
98
97
|
});
|
|
99
|
-
/**
|
|
100
|
-
* Reference to a symbol in a proprietary symbol set (e.g., SymbolStix).
|
|
101
|
-
*/
|
|
98
|
+
/** Reference to a symbol in a proprietary symbol set (e.g., SymbolStix). */
|
|
102
99
|
const OBFSymbolInfoSchema = z.looseObject({
|
|
103
|
-
/** Name of the symbol set, e.g.,
|
|
100
|
+
/** Name of the symbol set, e.g., `symbolstix`. */
|
|
104
101
|
set: z.string(),
|
|
105
102
|
/** Filename of the symbol within the set. */
|
|
106
103
|
filename: z.string()
|
|
@@ -127,19 +124,20 @@ const OBFImageSchema = OBFMediaSchema.extend({
|
|
|
127
124
|
* Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.
|
|
128
125
|
*/
|
|
129
126
|
const OBFSoundSchema = OBFMediaSchema;
|
|
130
|
-
/**
|
|
131
|
-
* Reference to another board, resolved by ID, path, or URL.
|
|
132
|
-
*/
|
|
127
|
+
/** Reference to another board, resolved by ID, path, or URL. */
|
|
133
128
|
const OBFLoadBoardSchema = z.looseObject({
|
|
134
129
|
/** Unique identifier of the board to load. */
|
|
135
130
|
id: OBFOptionalIDSchema,
|
|
136
131
|
/** Name of the board to load. */
|
|
137
132
|
name: z.string().optional(),
|
|
138
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* URL of an API endpoint for fetching the board programmatically —
|
|
135
|
+
* not a `data:` URI.
|
|
136
|
+
*/
|
|
139
137
|
data_url: OBFOptionalUrlSchema,
|
|
140
138
|
/** URL to access the board via a web browser. */
|
|
141
139
|
url: OBFOptionalUrlSchema,
|
|
142
|
-
/** Path to the board within an
|
|
140
|
+
/** Path to the board within an `.obz` package. */
|
|
143
141
|
path: z.string().optional()
|
|
144
142
|
});
|
|
145
143
|
/**
|
|
@@ -156,15 +154,21 @@ const OBFButtonSchema = z.looseObject({
|
|
|
156
154
|
image_id: OBFOptionalIDSchema,
|
|
157
155
|
/** Identifier of the sound associated with the button. */
|
|
158
156
|
sound_id: OBFOptionalIDSchema,
|
|
159
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Action triggered by the button. When `actions` is also set, this is
|
|
159
|
+
* the single-action fallback for apps that support one action per button.
|
|
160
|
+
*/
|
|
160
161
|
action: OBFButtonActionSchema.optional(),
|
|
161
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* Multiple actions executed in order. Apps that support it should
|
|
164
|
+
* prefer this over the single `action` fallback.
|
|
165
|
+
*/
|
|
162
166
|
actions: z.array(OBFButtonActionSchema).optional(),
|
|
163
167
|
/** Information to load another board when this button is activated. */
|
|
164
168
|
load_board: OBFLoadBoardSchema.optional(),
|
|
165
|
-
/** Background color of the button in
|
|
169
|
+
/** Background color of the button in `rgb` or `rgba` format. */
|
|
166
170
|
background_color: z.string().optional(),
|
|
167
|
-
/** Border color of the button in
|
|
171
|
+
/** Border color of the button in `rgb` or `rgba` format. */
|
|
168
172
|
border_color: z.string().optional(),
|
|
169
173
|
/** Vertical position for absolute positioning (0.0 to 1.0). */
|
|
170
174
|
top: z.number().min(0).max(1).optional(),
|
|
@@ -193,11 +197,11 @@ const OBFGridSchema = z.looseObject({
|
|
|
193
197
|
* Root object of an `.obf` file: the complete definition of a single communication board.
|
|
194
198
|
*/
|
|
195
199
|
const OBFBoardSchema = z.looseObject({
|
|
196
|
-
/** Format version of the Open Board Format, e.g.,
|
|
200
|
+
/** Format version of the Open Board Format, e.g., `open-board-0.1`. */
|
|
197
201
|
format: OBFFormatVersionSchema,
|
|
198
202
|
/** Unique identifier for the board. */
|
|
199
203
|
id: OBFIDSchema,
|
|
200
|
-
/** Locale of the board as a BCP 47 language tag, e.g.,
|
|
204
|
+
/** Locale of the board as a BCP 47 language tag, e.g., `en`, `en-US`. */
|
|
201
205
|
locale: OBFLocaleCodeSchema.optional(),
|
|
202
206
|
/** List of buttons on the board. */
|
|
203
207
|
buttons: z.array(OBFButtonSchema),
|
|
@@ -222,9 +226,9 @@ const OBFBoardSchema = z.looseObject({
|
|
|
222
226
|
* Table of contents for an `.obz` package, mapping resource IDs to their archive paths.
|
|
223
227
|
*/
|
|
224
228
|
const OBFManifestSchema = z.looseObject({
|
|
225
|
-
/** Format version of the Open Board Format, e.g.,
|
|
229
|
+
/** Format version of the Open Board Format, e.g., `open-board-0.1`. */
|
|
226
230
|
format: OBFFormatVersionSchema,
|
|
227
|
-
/** Path to the root board within the
|
|
231
|
+
/** Path to the root board within the `.obz` package. */
|
|
228
232
|
root: z.string(),
|
|
229
233
|
/** Mapping of IDs to paths for boards, images, and sounds. */
|
|
230
234
|
paths: z.looseObject({
|
|
@@ -246,7 +250,12 @@ const UTF8_BOM = "";
|
|
|
246
250
|
function stripBom(text) {
|
|
247
251
|
return text.startsWith(UTF8_BOM) ? text.slice(1) : text;
|
|
248
252
|
}
|
|
249
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* Build a descriptive JSON parse-failure message, preserving the engine's
|
|
255
|
+
* reason when available.
|
|
256
|
+
*
|
|
257
|
+
* @internal Exported for reuse by the OBZ module — not part of the public API.
|
|
258
|
+
*/
|
|
250
259
|
function buildJsonParseErrorMessage(label, error) {
|
|
251
260
|
const reason = error instanceof Error ? error.message : "";
|
|
252
261
|
return reason ? `Invalid ${label}: JSON parse failed — ${reason}` : `Invalid ${label}: JSON parse failed`;
|
|
@@ -260,7 +269,7 @@ function buildJsonParseErrorMessage(label, error) {
|
|
|
260
269
|
* @param json - The JSON string to parse.
|
|
261
270
|
* @returns The validated board object.
|
|
262
271
|
*
|
|
263
|
-
* @throws {Error} If the JSON is malformed or
|
|
272
|
+
* @throws {Error} If the JSON is malformed or fails schema validation.
|
|
264
273
|
*/
|
|
265
274
|
function parseOBF(json) {
|
|
266
275
|
const sanitized = stripBom(json);
|
|
@@ -292,7 +301,7 @@ async function loadOBF(file) {
|
|
|
292
301
|
* @param data - The value to validate.
|
|
293
302
|
* @returns The validated board object.
|
|
294
303
|
*
|
|
295
|
-
* @throws {Error} If the value
|
|
304
|
+
* @throws {Error} If the value fails schema validation.
|
|
296
305
|
*/
|
|
297
306
|
function validateOBF(data) {
|
|
298
307
|
const result = OBFBoardSchema.safeParse(data);
|
|
@@ -311,6 +320,9 @@ function stringifyOBF(board) {
|
|
|
311
320
|
//#endregion
|
|
312
321
|
//#region src/zip.ts
|
|
313
322
|
/**
|
|
323
|
+
* Minimal ZIP helpers over fflate: signature sniffing, unzip, and zip.
|
|
324
|
+
*/
|
|
325
|
+
/**
|
|
314
326
|
* First two bytes of every ZIP archive — the ASCII letters `PK`,
|
|
315
327
|
* after Phil Katz, creator of the format.
|
|
316
328
|
*
|
|
@@ -318,7 +330,7 @@ function stringifyOBF(board) {
|
|
|
318
330
|
* test lightweight and sufficient for distinguishing ZIP from JSON.
|
|
319
331
|
*/
|
|
320
332
|
const ZIP_MAGIC = [80, 75];
|
|
321
|
-
/** Balanced speed-vs-size deflate level
|
|
333
|
+
/** Balanced speed-vs-size deflate level, on fflate's 0–9 scale (0 = store). */
|
|
322
334
|
const COMPRESSION_LEVEL = 6;
|
|
323
335
|
/**
|
|
324
336
|
* Decompress a ZIP archive into a map of file paths to raw bytes.
|
|
@@ -326,7 +338,7 @@ const COMPRESSION_LEVEL = 6;
|
|
|
326
338
|
* @param archive - The ZIP archive as an `ArrayBuffer`.
|
|
327
339
|
* @returns A map of file paths to their decompressed content.
|
|
328
340
|
*
|
|
329
|
-
* @throws {Error} If
|
|
341
|
+
* @throws {Error} If the archive is corrupt or cannot be decompressed.
|
|
330
342
|
*/
|
|
331
343
|
function unzip(archive) {
|
|
332
344
|
return new Promise((resolve, reject) => {
|
|
@@ -349,7 +361,7 @@ function unzip(archive) {
|
|
|
349
361
|
* @param entries - A map of file paths to their content bytes.
|
|
350
362
|
* @returns The compressed archive as a `Uint8Array`.
|
|
351
363
|
*
|
|
352
|
-
* @throws {Error} If
|
|
364
|
+
* @throws {Error} If fflate fails to compress an entry.
|
|
353
365
|
*/
|
|
354
366
|
function zip(entries) {
|
|
355
367
|
return new Promise((resolve, reject) => {
|
|
@@ -378,15 +390,18 @@ function isZip(archive) {
|
|
|
378
390
|
//#endregion
|
|
379
391
|
//#region src/obz.ts
|
|
380
392
|
/**
|
|
393
|
+
* Creation and extraction of `.obz` board packages.
|
|
394
|
+
*/
|
|
395
|
+
/**
|
|
381
396
|
* Read a `File` and extract its contents as a parsed OBZ package.
|
|
382
397
|
*
|
|
383
398
|
* This relies on the browser `File` API; for Node environments,
|
|
384
399
|
* read the file to an `ArrayBuffer` and pass it to {@link extractOBZ} instead.
|
|
385
400
|
*
|
|
386
401
|
* @param file - A `File` handle pointing to an `.obz` archive.
|
|
387
|
-
* @returns The parsed manifest, boards, and binary resources.
|
|
402
|
+
* @returns The parsed manifest, boards, root board, and binary resources.
|
|
388
403
|
*
|
|
389
|
-
* @throws {Error}
|
|
404
|
+
* @throws {Error} Same failures as {@link extractOBZ}, which this delegates to.
|
|
390
405
|
*/
|
|
391
406
|
async function loadOBZ(file) {
|
|
392
407
|
return extractOBZ(await file.arrayBuffer());
|
|
@@ -396,17 +411,22 @@ async function loadOBZ(file) {
|
|
|
396
411
|
*
|
|
397
412
|
* @param archive - The OBZ archive as an `ArrayBuffer`.
|
|
398
413
|
* @returns The parsed manifest, a map of board IDs to validated boards,
|
|
399
|
-
* and a map of file paths to their
|
|
414
|
+
* the resolved root board, and a map of file paths to their
|
|
415
|
+
* binary content.
|
|
400
416
|
*
|
|
401
|
-
* @throws {Error} If the archive is not a valid ZIP
|
|
417
|
+
* @throws {Error} If the archive is not a valid ZIP, the manifest is missing,
|
|
418
|
+
* a board declared in the manifest is missing or fails validation, or a
|
|
419
|
+
* board's `id` differs from the ID the manifest declares for it.
|
|
402
420
|
*/
|
|
403
421
|
async function extractOBZ(archive) {
|
|
404
422
|
if (!isZip(archive)) throw new Error("Invalid OBZ: not a ZIP file");
|
|
405
423
|
const entries = await unzip(archive);
|
|
406
424
|
const manifest = extractManifest(entries);
|
|
425
|
+
const { boards, rootBoard } = extractBoards(manifest, entries);
|
|
407
426
|
return {
|
|
408
427
|
manifest,
|
|
409
|
-
boards
|
|
428
|
+
boards,
|
|
429
|
+
rootBoard,
|
|
410
430
|
resources: entries
|
|
411
431
|
};
|
|
412
432
|
}
|
|
@@ -442,9 +462,9 @@ function parseManifest(json) {
|
|
|
442
462
|
* @returns A `Blob` containing the compressed OBZ archive.
|
|
443
463
|
*
|
|
444
464
|
* @throws {Error} If `rootBoardId` does not match any of the supplied boards.
|
|
445
|
-
* @throws {Error} If two supplied boards share the same
|
|
465
|
+
* @throws {Error} If two supplied boards share the same ID.
|
|
446
466
|
* @throws {Error} If a supplied board fails schema validation.
|
|
447
|
-
* @throws {Error} If two boards declare the same media
|
|
467
|
+
* @throws {Error} If two boards declare the same media ID with conflicting paths.
|
|
448
468
|
* @throws {Error} If a board declares an image or sound `path` with no matching entry in `resources`.
|
|
449
469
|
* @throws {Error} If a `resources` entry would overwrite the generated `manifest.json` or a board file.
|
|
450
470
|
*/
|
|
@@ -491,7 +511,7 @@ async function createOBZ(boards, rootBoardId, resources) {
|
|
|
491
511
|
* Walk every board's media collection and produce the `{ id -> path }` map
|
|
492
512
|
* the spec calls "redundant but still required" for the OBZ manifest.
|
|
493
513
|
*
|
|
494
|
-
* Throws when two boards declare the same media
|
|
514
|
+
* Throws when two boards declare the same media ID with conflicting paths
|
|
495
515
|
* — a silent OBZ that points at a non-existent file is worse than a clear error.
|
|
496
516
|
*/
|
|
497
517
|
function collectMediaPaths(boards, kind) {
|
|
@@ -521,25 +541,35 @@ function extractManifest(entries) {
|
|
|
521
541
|
}
|
|
522
542
|
function extractBoards(manifest, entries) {
|
|
523
543
|
const boards = /* @__PURE__ */ new Map();
|
|
544
|
+
let rootBoard;
|
|
524
545
|
for (const [id, path] of Object.entries(manifest.paths.boards)) {
|
|
525
546
|
const boardBytes = entries.get(path);
|
|
526
547
|
if (!boardBytes) throw new Error(`Invalid OBZ: board "${id}" declared in manifest but missing at path "${path}"`);
|
|
527
|
-
const
|
|
528
|
-
|
|
548
|
+
const board = parseOBF(new TextDecoder().decode(boardBytes));
|
|
549
|
+
if (board.id !== id) throw new Error(`Invalid OBZ: board at "${path}" has id "${board.id}" but the manifest declares it as "${id}"`);
|
|
550
|
+
boards.set(id, board);
|
|
551
|
+
if (path === manifest.root) rootBoard = board;
|
|
529
552
|
}
|
|
530
|
-
|
|
553
|
+
if (!rootBoard) throw new Error(`Invalid OBZ: root board "${manifest.root}" not found in paths.boards`);
|
|
554
|
+
return {
|
|
555
|
+
boards,
|
|
556
|
+
rootBoard
|
|
557
|
+
};
|
|
531
558
|
}
|
|
532
559
|
//#endregion
|
|
533
560
|
//#region src/load-board.ts
|
|
534
561
|
/**
|
|
562
|
+
* Format-agnostic loading of `.obf` boards and `.obz` packages.
|
|
563
|
+
*/
|
|
564
|
+
/**
|
|
535
565
|
* Detect whether the input is a single OBF board or an OBZ package and load it
|
|
536
566
|
* accordingly.
|
|
537
567
|
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
568
|
+
* Input that begins with the ZIP magic prefix is treated as an `.obz` package;
|
|
569
|
+
* anything else is parsed as an `.obf` board. The input is read once, so
|
|
570
|
+
* consumers can accept either format from a single drag-and-drop, file picker,
|
|
571
|
+
* or fetch response without inspecting the file extension or re-deriving the
|
|
572
|
+
* OBF-vs-OBZ distinction themselves.
|
|
543
573
|
*
|
|
544
574
|
* @param input - A `File` handle or `ArrayBuffer` holding `.obf` or `.obz` content.
|
|
545
575
|
* @returns A discriminated union tagged by `format`.
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/schema.ts","../src/obf.ts","../src/zip.ts","../src/obz.ts","../src/load-board.ts"],"sourcesContent":["/**\n * Open Board Format (OBF) Zod Schemas\n *\n * These schemas represent the Open Board Format, designed for sharing communication boards and board sets\n * between Augmentative and Alternative Communication (AAC) applications.\n *\n * Official OBF specification: https://www.openboardformat.org/docs\n *\n * @author Shay Cojocaru\n * @license MIT\n */\n\nimport { z } from \"zod\";\n\n/** Optional URL that treats empty strings as undefined. */\nconst OBFOptionalUrlSchema = z\n .union([z.url(), z.literal(\"\")])\n .transform((val) => (val === \"\" ? undefined : val))\n .optional();\n\n/** Optional email that treats empty strings as undefined. */\nconst OBFOptionalEmailSchema = z\n .union([z.email(), z.literal(\"\")])\n .transform((val) => (val === \"\" ? undefined : val))\n .optional();\n\n/** Optional ID that treats empty strings as undefined. */\nconst OBFOptionalIDSchema = z\n .union([z.string(), z.number()])\n .transform((val) => {\n const str = String(val);\n return str === \"\" ? undefined : str;\n })\n .optional();\n\n/** Unique board-element identifier, coerced to a non-empty string. */\nexport const OBFIDSchema = z\n .union([z.string(), z.number()])\n .transform((val) => String(val))\n .pipe(z.string().min(1));\nexport type OBFID = z.infer<typeof OBFIDSchema>;\n\n/**\n * Format version of the Open Board Format, e.g., 'open-board-0.1'.\n */\nexport const OBFFormatVersionSchema = z.string().regex(/^open-board-.+$/);\nexport type OBFFormatVersion = z.infer<typeof OBFFormatVersionSchema>;\n\n/**\n * Locale identifier, typically a BCP 47 language tag (e.g., 'en', 'en-US', 'fr-CA').\n * Not strictly validated — any string is accepted.\n */\nexport const OBFLocaleCodeSchema = z.string();\nexport type OBFLocaleCode = z.infer<typeof OBFLocaleCodeSchema>;\n\n/**\n * Key–value pairs mapping symbolic names to their translations in a single locale.\n */\nexport const OBFLocalizedStringsSchema = z.record(z.string(), z.string());\nexport type OBFLocalizedStrings = z.infer<typeof OBFLocalizedStringsSchema>;\n\n/**\n * Locale-keyed dictionary of translated strings,\n * e.g., `{ en: { greeting: \"Hello\" }, fr: { greeting: \"Bonjour\" } }`.\n */\nexport const OBFStringsSchema = z.record(z.string(), OBFLocalizedStringsSchema);\nexport type OBFStrings = z.infer<typeof OBFStringsSchema>;\n\n/**\n * Spelling action: a `+` prefix followed by the text to append,\n * e.g., `\"+hello\"`.\n */\nexport const OBFSpellingActionSchema = z.string().regex(/^\\+.+$/);\nexport type OBFSpellingAction = z.infer<typeof OBFSpellingActionSchema>;\n\n/**\n * Specialty action prefixed with `:`, e.g., `\":clear\"`.\n * Custom extensions use the `:ext_` prefix.\n */\nexport const OBFSpecialtyActionSchema = z\n .string()\n .regex(/^:[a-z][a-z0-9_-]*$/i);\nexport type OBFSpecialtyAction = z.infer<typeof OBFSpecialtyActionSchema>;\n\n/**\n * Union of spelling and specialty actions that a button can trigger.\n */\nexport const OBFButtonActionSchema = z.union([\n OBFSpellingActionSchema,\n OBFSpecialtyActionSchema,\n]);\nexport type OBFButtonAction = z.infer<typeof OBFButtonActionSchema>;\n\n/**\n * License terms and attribution for a resource.\n */\nexport const OBFLicenseSchema = z.looseObject({\n /** Type of the license, e.g., 'CC-BY-SA'. */\n type: z.string(),\n /** URL to the license terms. */\n copyright_notice_url: OBFOptionalUrlSchema,\n /** Source URL of the resource. */\n source_url: OBFOptionalUrlSchema,\n /** Name of the author. */\n author_name: z.string().optional(),\n /** URL of the author's webpage. */\n author_url: OBFOptionalUrlSchema,\n /** Email address of the author. */\n author_email: OBFOptionalEmailSchema,\n});\n\nexport type OBFLicense = z.infer<typeof OBFLicenseSchema>;\n\n/**\n * Common properties for media resources (images and sounds).\n *\n * When multiple references are provided, they should be used in the following order:\n * 1. data\n * 2. path\n * 3. url\n */\nexport const OBFMediaSchema = z.looseObject({\n /** Unique identifier for the media resource. */\n id: OBFIDSchema,\n /** Data URI containing the media data. */\n data: z.string().optional(),\n /** Path to the media file within an .obz package. */\n path: z.string().optional(),\n /** Data URL to fetch the media programmatically. */\n data_url: OBFOptionalUrlSchema,\n /** URL to the media resource. */\n url: OBFOptionalUrlSchema,\n /** MIME type of the media, e.g., 'image/png', 'audio/mpeg'. */\n content_type: z.string().optional(),\n /** Licensing information for the media. */\n license: OBFLicenseSchema.optional(),\n});\n\nexport type OBFMedia = z.infer<typeof OBFMediaSchema>;\n\n/**\n * Reference to a symbol in a proprietary symbol set (e.g., SymbolStix).\n */\nexport const OBFSymbolInfoSchema = z.looseObject({\n /** Name of the symbol set, e.g., 'symbolstix'. */\n set: z.string(),\n /** Filename of the symbol within the set. */\n filename: z.string(),\n});\nexport type OBFSymbolInfo = z.infer<typeof OBFSymbolInfoSchema>;\n\n/**\n * Image resource, extending {@link OBFMediaSchema} with optional\n * symbol and dimension properties.\n *\n * When resolving the image, consumers should prefer sources in this order:\n * 1. `data`\n * 2. `path`\n * 3. `url`\n * 4. `symbol`\n */\nexport const OBFImageSchema = OBFMediaSchema.extend({\n /** Information about a symbol from a proprietary symbol set. */\n symbol: OBFSymbolInfoSchema.optional(),\n /** Width of the image in pixels. */\n width: z.number().optional(),\n /** Height of the image in pixels. */\n height: z.number().optional(),\n});\nexport type OBFImage = z.infer<typeof OBFImageSchema>;\n\n/**\n * Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.\n */\nexport const OBFSoundSchema = OBFMediaSchema;\nexport type OBFSound = z.infer<typeof OBFSoundSchema>;\n\n/**\n * Reference to another board, resolved by ID, path, or URL.\n */\nexport const OBFLoadBoardSchema = z.looseObject({\n /** Unique identifier of the board to load. */\n id: OBFOptionalIDSchema,\n /** Name of the board to load. */\n name: z.string().optional(),\n /** Data URL to fetch the board programmatically. */\n data_url: OBFOptionalUrlSchema,\n /** URL to access the board via a web browser. */\n url: OBFOptionalUrlSchema,\n /** Path to the board within an .obz package. */\n path: z.string().optional(),\n});\n\nexport type OBFLoadBoard = z.infer<typeof OBFLoadBoardSchema>;\n\n/**\n * Interactive element on a board, optionally linked to images, sounds, and actions.\n */\nexport const OBFButtonSchema = z.looseObject({\n /** Unique identifier for the button. */\n id: OBFIDSchema,\n /** Label text displayed on the button. */\n label: z.string().optional(),\n /** Alternative text for vocalization when the button is activated. */\n vocalization: z.string().optional(),\n /** Identifier of the image associated with the button. */\n image_id: OBFOptionalIDSchema,\n /** Identifier of the sound associated with the button. */\n sound_id: OBFOptionalIDSchema,\n /** Action associated with the button. */\n action: OBFButtonActionSchema.optional(),\n /** List of multiple actions for the button, executed in order. */\n actions: z.array(OBFButtonActionSchema).optional(),\n /** Information to load another board when this button is activated. */\n load_board: OBFLoadBoardSchema.optional(),\n /** Background color of the button in 'rgb' or 'rgba' format. */\n background_color: z.string().optional(),\n /** Border color of the button in 'rgb' or 'rgba' format. */\n border_color: z.string().optional(),\n /** Vertical position for absolute positioning (0.0 to 1.0). */\n top: z.number().min(0).max(1).optional(),\n /** Horizontal position for absolute positioning (0.0 to 1.0). */\n left: z.number().min(0).max(1).optional(),\n /** Width of the button for absolute positioning (0.0 to 1.0). */\n width: z.number().min(0).max(1).optional(),\n /** Height of the button for absolute positioning (0.0 to 1.0). */\n height: z.number().min(0).max(1).optional(),\n});\n\nexport type OBFButton = z.infer<typeof OBFButtonSchema>;\n\n/**\n * Row-and-column layout that arranges buttons by their IDs.\n */\nexport const OBFGridSchema = z\n .looseObject({\n /** Number of rows in the grid. */\n rows: z.number().int().min(1),\n /** Number of columns in the grid. */\n columns: z.number().int().min(1),\n /**\n * 2D array representing the order of buttons by their IDs.\n * Each sub-array corresponds to a row, and each element is a button ID or null for empty slots.\n */\n order: z.array(z.array(z.union([OBFIDSchema, z.null()]))),\n })\n .refine((g) => g.order.length === g.rows, {\n message: \"Grid order length must match rows\",\n })\n .refine((g) => g.order.every((row) => row.length === g.columns), {\n message: \"Each grid row must have length equal to columns\",\n });\nexport type OBFGrid = z.infer<typeof OBFGridSchema>;\n\n/**\n * Root object of an `.obf` file: the complete definition of a single communication board.\n */\nexport const OBFBoardSchema = z.looseObject({\n /** Format version of the Open Board Format, e.g., 'open-board-0.1'. */\n format: OBFFormatVersionSchema,\n /** Unique identifier for the board. */\n id: OBFIDSchema,\n /** Locale of the board as a BCP 47 language tag, e.g., 'en', 'en-US'. */\n locale: OBFLocaleCodeSchema.optional(),\n /** List of buttons on the board. */\n buttons: z.array(OBFButtonSchema),\n /** URL where the board can be accessed or downloaded. */\n url: OBFOptionalUrlSchema,\n /** Name of the board. */\n name: z.string().optional(),\n /** Description of the board in HTML format. */\n description_html: z.string().optional(),\n /** Grid layout information for arranging buttons. */\n grid: OBFGridSchema,\n /** List of images used in the board. */\n images: z.array(OBFImageSchema).optional(),\n /** List of sounds used in the board. */\n sounds: z.array(OBFSoundSchema).optional(),\n /** Licensing information for the board. */\n license: OBFLicenseSchema.optional(),\n /** String translations for multiple locales. */\n strings: OBFStringsSchema.optional(),\n});\n\nexport type OBFBoard = z.infer<typeof OBFBoardSchema>;\n\n/**\n * Table of contents for an `.obz` package, mapping resource IDs to their archive paths.\n */\nexport const OBFManifestSchema = z\n .looseObject({\n /** Format version of the Open Board Format, e.g., 'open-board-0.1'. */\n format: OBFFormatVersionSchema,\n /** Path to the root board within the .obz package. */\n root: z.string(),\n /** Mapping of IDs to paths for boards, images, and sounds. */\n paths: z.looseObject({\n /** Mapping of board IDs to their file paths. */\n boards: z.record(z.string(), z.string()),\n /** Mapping of image IDs to their file paths. */\n images: z.record(z.string(), z.string()),\n /** Mapping of sound IDs to their file paths. */\n sounds: z.record(z.string(), z.string()).optional(),\n }),\n })\n .refine((m) => Object.values(m.paths.boards).includes(m.root), {\n message: \"root must be listed in paths.boards\",\n path: [\"root\"],\n });\nexport type OBFManifest = z.infer<typeof OBFManifestSchema>;\n","import type { OBFBoard } from \"./schema\";\nimport { OBFBoardSchema } from \"./schema\";\n\nconst UTF8_BOM = \"\\uFEFF\";\n\n/** Strip a leading UTF-8 BOM, which some editors silently prepend. */\nfunction stripBom(text: string): string {\n return text.startsWith(UTF8_BOM) ? text.slice(1) : text;\n}\n\n/** Build a descriptive JSON parse-failure message, preserving the engine's reason when available. */\nexport function buildJsonParseErrorMessage(\n label: string,\n error: unknown,\n): string {\n const reason = error instanceof Error ? error.message : \"\";\n return reason\n ? `Invalid ${label}: JSON parse failed — ${reason}`\n : `Invalid ${label}: JSON parse failed`;\n}\n\n/**\n * Parse a JSON string into a validated OBF board.\n *\n * Strips an optional UTF-8 BOM prefix before parsing and throws a\n * descriptive error if the input is malformed or fails schema validation.\n *\n * @param json - The JSON string to parse.\n * @returns The validated board object.\n *\n * @throws {Error} If the JSON is malformed or does not conform to the OBF schema.\n */\nexport function parseOBF(json: string): OBFBoard {\n const sanitized = stripBom(json);\n\n let rawBoard: unknown;\n\n try {\n rawBoard = JSON.parse(sanitized) as unknown;\n } catch (error) {\n throw new Error(buildJsonParseErrorMessage(\"OBF\", error), { cause: error });\n }\n\n return validateOBF(rawBoard);\n}\n\n/**\n * Read a `File` and parse its contents as a validated OBF board.\n *\n * This relies on the browser `File` API; for Node environments,\n * read the file to a string and pass it to {@link parseOBF} instead.\n *\n * @param file - A `File` handle pointing to an `.obf` file.\n * @returns The validated board object.\n *\n * @throws {Error} If the file content is malformed or fails schema validation.\n */\nexport async function loadOBF(file: File): Promise<OBFBoard> {\n const json = await file.text();\n return parseOBF(json);\n}\n\n/**\n * Validate an unknown value against the OBF board schema.\n *\n * @param data - The value to validate.\n * @returns The validated board object.\n *\n * @throws {Error} If the value does not conform to the OBF schema.\n */\nexport function validateOBF(data: unknown): OBFBoard {\n const result = OBFBoardSchema.safeParse(data);\n\n if (!result.success) {\n throw new Error(`Invalid OBF: ${result.error.message}`);\n }\n\n return result.data;\n}\n\n/**\n * Stringify an OBF board to a pretty-printed JSON string.\n *\n * @param board - The board to stringify.\n * @returns A JSON string with two-space indentation.\n */\nexport function stringifyOBF(board: OBFBoard): string {\n return JSON.stringify(board, null, 2);\n}\n","import { unzip as fflateUnzip, zip as fflateZip } from \"fflate\";\n\n/**\n * First two bytes of every ZIP archive — the ASCII letters `PK`,\n * after Phil Katz, creator of the format.\n *\n * Only the 2-byte prefix is checked intentionally: this keeps the\n * test lightweight and sufficient for distinguishing ZIP from JSON.\n */\nconst ZIP_MAGIC = [0x50, 0x4b] as const;\n\n/** Balanced speed-vs-size deflate level used by fflate (1–9 scale). */\nconst COMPRESSION_LEVEL = 6;\n\n/**\n * Decompress a ZIP archive into a map of file paths to raw bytes.\n *\n * @param archive - The ZIP archive as an `ArrayBuffer`.\n * @returns A map of file paths to their decompressed content.\n *\n * @throws {Error} If decompression fails.\n */\nexport function unzip(archive: ArrayBuffer): Promise<Map<string, Uint8Array>> {\n return new Promise((resolve, reject) => {\n const compressed = new Uint8Array(archive);\n\n fflateUnzip(compressed, (error, entries) => {\n if (error) {\n reject(new Error(`Failed to unzip: ${error.message ?? String(error)}`));\n return;\n }\n\n const pathToBytes = new Map<string, Uint8Array>(Object.entries(entries));\n\n resolve(pathToBytes);\n });\n });\n}\n\n/**\n * Compress a map of file paths and contents into a single ZIP archive.\n *\n * Accepts both `Uint8Array` and `ArrayBuffer` values so callers can\n * pass the output of {@link unzip} directly or supply raw `ArrayBuffer`s\n * without converting first.\n *\n * @param entries - A map of file paths to their content bytes.\n * @returns The compressed archive as a `Uint8Array`.\n *\n * @throws {Error} If compression fails.\n */\nexport function zip(\n entries: Map<string, Uint8Array | ArrayBuffer>,\n): Promise<Uint8Array> {\n return new Promise((resolve, reject) => {\n const pathToBytes: Record<string, Uint8Array> = {};\n\n for (const [path, content] of entries) {\n pathToBytes[path] =\n content instanceof Uint8Array ? content : new Uint8Array(content);\n }\n\n fflateZip(pathToBytes, { level: COMPRESSION_LEVEL }, (error, result) => {\n if (error) {\n reject(new Error(`Failed to zip: ${error.message ?? String(error)}`));\n return;\n }\n\n resolve(result);\n });\n });\n}\n\n/**\n * Test whether an `ArrayBuffer` begins with the two-byte ZIP magic\n * prefix (`PK`).\n *\n * @param archive - The buffer to inspect.\n * @returns `true` if the buffer starts with the ZIP signature.\n */\nexport function isZip(archive: ArrayBuffer): boolean {\n const bytes = new Uint8Array(archive);\n\n return (\n bytes.length >= ZIP_MAGIC.length &&\n ZIP_MAGIC.every((byte, index) => bytes[index] === byte)\n );\n}\n","import { buildJsonParseErrorMessage, parseOBF } from \"./obf\";\nimport type { OBFBoard, OBFManifest } from \"./schema\";\nimport { OBFBoardSchema, OBFManifestSchema } from \"./schema\";\nimport { isZip, unzip, zip } from \"./zip\";\n\n/**\n * Fully extracted contents of an `.obz` archive.\n *\n * @property manifest - The package table of contents.\n * @property boards - Board ID → validated board object.\n * @property resources - Archive path → raw bytes for every entry in the archive,\n * including `manifest.json` and the `.obf` boards as well as\n * media such as images and sounds.\n */\nexport interface ParsedOBZ {\n manifest: OBFManifest;\n boards: Map<string, OBFBoard>;\n resources: Map<string, Uint8Array>;\n}\n\n/**\n * Read a `File` and extract its contents as a parsed OBZ package.\n *\n * This relies on the browser `File` API; for Node environments,\n * read the file to an `ArrayBuffer` and pass it to {@link extractOBZ} instead.\n *\n * @param file - A `File` handle pointing to an `.obz` archive.\n * @returns The parsed manifest, boards, and binary resources.\n *\n * @throws {Error} If the file is not a valid ZIP or the manifest is missing.\n */\nexport async function loadOBZ(file: File): Promise<ParsedOBZ> {\n const archive = await file.arrayBuffer();\n return extractOBZ(archive);\n}\n\n/**\n * Decompress an OBZ archive and return its manifest, boards, and resources.\n *\n * @param archive - The OBZ archive as an `ArrayBuffer`.\n * @returns The parsed manifest, a map of board IDs to validated boards,\n * and a map of file paths to their binary content.\n *\n * @throws {Error} If the archive is not a valid ZIP or the manifest is missing.\n */\nexport async function extractOBZ(archive: ArrayBuffer): Promise<ParsedOBZ> {\n if (!isZip(archive)) {\n throw new Error(\"Invalid OBZ: not a ZIP file\");\n }\n\n const entries = await unzip(archive);\n\n const manifest = extractManifest(entries);\n const boards = extractBoards(manifest, entries);\n\n return { manifest, boards, resources: entries };\n}\n\n/**\n * Parse and validate an OBZ manifest — the table of contents that maps\n * board IDs to their file paths within the archive.\n *\n * @param json - A JSON string representing the manifest.\n * @returns The validated manifest object.\n *\n * @throws {Error} If the JSON is malformed or fails schema validation.\n */\nexport function parseManifest(json: string): OBFManifest {\n let data: unknown;\n\n try {\n data = JSON.parse(json) as unknown;\n } catch (error) {\n throw new Error(buildJsonParseErrorMessage(\"manifest\", error), {\n cause: error,\n });\n }\n\n const result = OBFManifestSchema.safeParse(data);\n\n if (!result.success) {\n throw new Error(`Invalid manifest: ${result.error.message}`);\n }\n\n return result.data;\n}\n\n/**\n * Bundle boards and optional resources into a compressed OBZ archive.\n *\n * A manifest is generated automatically from the supplied boards,\n * using the `rootBoardId` to designate the entry-point board.\n *\n * @param boards - The boards to include in the archive.\n * @param rootBoardId - The ID of the board that serves as the archive's entry point.\n * @param resources - Optional map of file paths to binary content (images, sounds, etc.).\n * @returns A `Blob` containing the compressed OBZ archive.\n *\n * @throws {Error} If `rootBoardId` does not match any of the supplied boards.\n * @throws {Error} If two supplied boards share the same id.\n * @throws {Error} If a supplied board fails schema validation.\n * @throws {Error} If two boards declare the same media id with conflicting paths.\n * @throws {Error} If a board declares an image or sound `path` with no matching entry in `resources`.\n * @throws {Error} If a `resources` entry would overwrite the generated `manifest.json` or a board file.\n */\nexport async function createOBZ(\n boards: OBFBoard[],\n rootBoardId: string,\n resources?: Map<string, Uint8Array | ArrayBuffer>,\n): Promise<Blob> {\n if (!boards.some((board) => board.id === rootBoardId)) {\n throw new Error(\n `Invalid OBZ: rootBoardId \"${rootBoardId}\" does not match any supplied board`,\n );\n }\n\n const seenBoardIds = new Set<string>();\n for (const board of boards) {\n if (seenBoardIds.has(board.id)) {\n throw new Error(\n `Invalid OBZ: duplicate board id \"${board.id}\" — board ids must be unique within a package`,\n );\n }\n seenBoardIds.add(board.id);\n }\n\n const entries = new Map<string, Uint8Array | ArrayBuffer>();\n\n const boardPaths = Object.fromEntries(\n boards.map((board) => [board.id, `boards/${board.id}.obf`]),\n );\n\n const imagePaths = collectMediaPaths(boards, \"images\");\n const soundPaths = collectMediaPaths(boards, \"sounds\");\n\n const manifestResult = OBFManifestSchema.safeParse({\n format: \"open-board-0.1\",\n root: `boards/${rootBoardId}.obf`,\n paths: {\n boards: boardPaths,\n images: imagePaths,\n ...(Object.keys(soundPaths).length > 0 ? { sounds: soundPaths } : {}),\n },\n });\n\n if (!manifestResult.success) {\n throw new Error(\n `Invalid OBZ: generated manifest failed validation — ${manifestResult.error.message}`,\n );\n }\n\n const manifest = manifestResult.data;\n\n const encoder = new TextEncoder();\n\n entries.set(\n \"manifest.json\",\n encoder.encode(JSON.stringify(manifest, null, 2)),\n );\n\n for (const board of boards) {\n const result = OBFBoardSchema.safeParse(board);\n if (!result.success) {\n throw new Error(\n `Invalid OBZ: board \"${board.id}\" failed validation — ${result.error.message}`,\n );\n }\n const path = `boards/${result.data.id}.obf`;\n entries.set(path, encoder.encode(JSON.stringify(result.data, null, 2)));\n }\n\n if (resources) {\n for (const [path, bytes] of resources) {\n if (entries.has(path)) {\n throw new Error(\n `Invalid OBZ: resource path \"${path}\" collides with a generated board or manifest entry`,\n );\n }\n entries.set(path, bytes);\n }\n }\n\n assertPathsPresent(\"image\", imagePaths, entries);\n assertPathsPresent(\"sound\", soundPaths, entries);\n\n const compressed = await zip(entries);\n return new Blob([compressed], { type: \"application/zip\" });\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walk every board's media collection and produce the `{ id -> path }` map\n * the spec calls \"redundant but still required\" for the OBZ manifest.\n *\n * Throws when two boards declare the same media id with conflicting paths\n * — a silent OBZ that points at a non-existent file is worse than a clear error.\n */\nfunction collectMediaPaths(\n boards: OBFBoard[],\n kind: \"images\" | \"sounds\",\n): Record<string, string> {\n const paths: Record<string, string> = {};\n\n for (const board of boards) {\n for (const media of board[kind] ?? []) {\n if (media.path === undefined) {\n continue;\n }\n\n const existing = paths[media.id];\n if (existing !== undefined && existing !== media.path) {\n throw new Error(\n `Invalid OBZ: ${kind} id \"${media.id}\" maps to conflicting paths \"${existing}\" and \"${media.path}\"`,\n );\n }\n paths[media.id] = media.path;\n }\n }\n\n return paths;\n}\n\n/**\n * Assert that every media path the generated manifest declares exists as an\n * archive entry — the same contract {@link extractOBZ} assumes when reading.\n *\n * Only media that declared a `path` reach this check, so `url`/`data`-only\n * media are never flagged.\n */\nfunction assertPathsPresent(\n kind: \"image\" | \"sound\",\n paths: Record<string, string>,\n entries: Map<string, Uint8Array | ArrayBuffer>,\n): void {\n for (const [id, path] of Object.entries(paths)) {\n if (!entries.has(path)) {\n throw new Error(\n `Invalid OBZ: ${kind} \"${id}\" references \"${path}\" but no matching resource was supplied`,\n );\n }\n }\n}\n\nfunction extractManifest(entries: Map<string, Uint8Array>): OBFManifest {\n const manifestBytes = entries.get(\"manifest.json\");\n\n if (!manifestBytes) {\n throw new Error(\"Invalid OBZ: missing manifest.json\");\n }\n\n const manifestJson = new TextDecoder().decode(manifestBytes);\n return parseManifest(manifestJson);\n}\n\nfunction extractBoards(\n manifest: OBFManifest,\n entries: Map<string, Uint8Array>,\n): Map<string, OBFBoard> {\n const boards = new Map<string, OBFBoard>();\n\n for (const [id, path] of Object.entries(manifest.paths.boards)) {\n const boardBytes = entries.get(path);\n\n if (!boardBytes) {\n throw new Error(\n `Invalid OBZ: board \"${id}\" declared in manifest but missing at path \"${path}\"`,\n );\n }\n\n const boardJson = new TextDecoder().decode(boardBytes);\n boards.set(id, parseOBF(boardJson));\n }\n\n return boards;\n}\n","import { parseOBF } from \"./obf\";\nimport { extractOBZ } from \"./obz\";\nimport type { ParsedOBZ } from \"./obz\";\nimport type { OBFBoard } from \"./schema\";\nimport { isZip } from \"./zip\";\n\n/**\n * Result of {@link loadBoard} — a discriminated union over the two file\n * shapes the Open Board Format defines.\n *\n * Switch on `format` to narrow:\n *\n * ```ts\n * const loaded = await loadBoard(file);\n * if (loaded.format === \"obz\") {\n * loaded.archive.boards; // ParsedOBZ\n * } else {\n * loaded.board; // OBFBoard\n * }\n * ```\n */\nexport type LoadedBoard =\n | { format: \"obz\"; archive: ParsedOBZ }\n | { format: \"obf\"; board: OBFBoard };\n\n/**\n * Detect whether the input is a single OBF board or an OBZ package and load it\n * accordingly.\n *\n * The input is read once and its leading bytes are sniffed for the ZIP magic\n * prefix: a ZIP is treated as an `.obz` package, anything else as an `.obf`\n * board. This lets consumers accept either format from a single drag-and-drop,\n * file picker, or fetch response without inspecting the file extension or\n * re-deriving the OBF-vs-OBZ distinction themselves.\n *\n * @param input - A `File` handle or `ArrayBuffer` holding `.obf` or `.obz` content.\n * @returns A discriminated union tagged by `format`.\n *\n * @throws {Error} If an OBZ archive is malformed or its manifest is missing,\n * or if an OBF board is malformed or fails schema validation.\n */\nexport async function loadBoard(\n input: File | ArrayBuffer,\n): Promise<LoadedBoard> {\n const buffer =\n input instanceof ArrayBuffer ? input : await input.arrayBuffer();\n\n if (isZip(buffer)) {\n return { format: \"obz\", archive: await extractOBZ(buffer) };\n }\n\n return { format: \"obf\", board: parseOBF(new TextDecoder().decode(buffer)) };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/schema.ts","../src/obf.ts","../src/zip.ts","../src/obz.ts","../src/load-board.ts"],"sourcesContent":["/**\n * Open Board Format (OBF) Zod Schemas\n *\n * These schemas represent the Open Board Format, designed for sharing communication boards and board sets\n * between Augmentative and Alternative Communication (AAC) applications.\n *\n * Official OBF specification: https://www.openboardformat.org/docs\n */\n\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/**\n * Unique board-element identifier, coerced to a non-empty string.\n * See {@link OBFIDSchema}.\n */\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/**\n * Format version of the Open Board Format, e.g., `open-board-0.1`.\n * See {@link OBFFormatVersionSchema}.\n */\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/**\n * Locale identifier, typically a BCP 47 language tag, e.g., `en`, `en-US`.\n * See {@link OBFLocaleCodeSchema}.\n */\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/**\n * Translations for a single locale, keyed by the source string.\n * See {@link OBFLocalizedStringsSchema}.\n */\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/**\n * Locale-keyed dictionary of translated strings.\n * See {@link OBFStringsSchema}.\n */\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/**\n * Spelling action: a `+` prefix followed by the text to append, e.g., `+hello`.\n * See {@link OBFSpellingActionSchema}.\n */\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/**\n * Specialty action prefixed with `:`, e.g., `:clear`.\n * See {@link OBFSpecialtyActionSchema}.\n */\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/**\n * Union of spelling and specialty actions that a button can trigger.\n * See {@link OBFButtonActionSchema}.\n */\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/**\n * License terms and attribution for a resource.\n * See {@link OBFLicenseSchema}.\n */\nexport type OBFLicense = z.infer<typeof OBFLicenseSchema>;\n\n/**\n * Common properties for media resources (images and sounds).\n *\n * When multiple references are provided, they should be used in the following order:\n * 1. `data`\n * 2. `path`\n * 3. `url`\n *\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/**\n * Common properties for media resources (images and sounds).\n * See {@link OBFMediaSchema}.\n */\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/**\n * Reference to a symbol in a proprietary symbol set.\n * See {@link OBFSymbolInfoSchema}.\n */\nexport type OBFSymbolInfo = z.infer<typeof OBFSymbolInfoSchema>;\n\n/**\n * Image resource, extending {@link OBFMediaSchema} with optional\n * symbol and dimension properties.\n *\n * When resolving the image, consumers should prefer sources in this order:\n * 1. `data`\n * 2. `path`\n * 3. `url`\n * 4. `symbol`\n */\nexport const OBFImageSchema = OBFMediaSchema.extend({\n /** Information about a symbol from a proprietary symbol set. */\n symbol: OBFSymbolInfoSchema.optional(),\n /** Width of the image in pixels. */\n width: z.number().optional(),\n /** Height of the image in pixels. */\n height: z.number().optional(),\n});\n\n/**\n * Image resource with optional symbol and dimension properties.\n * See {@link OBFImageSchema}.\n */\nexport type OBFImage = z.infer<typeof OBFImageSchema>;\n\n/**\n * Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.\n */\nexport const OBFSoundSchema = OBFMediaSchema;\n\n/**\n * Audio resource, identical to {@link OBFMedia}.\n * See {@link OBFSoundSchema}.\n */\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/**\n * Reference to another board, resolved by ID, path, or URL.\n * See {@link OBFLoadBoardSchema}.\n */\nexport type OBFLoadBoard = z.infer<typeof OBFLoadBoardSchema>;\n\n/**\n * Interactive element on a board, optionally linked to images, sounds, and actions.\n */\nexport const OBFButtonSchema = z.looseObject({\n /** Unique identifier for the button. */\n id: OBFIDSchema,\n /** Label text displayed on the button. */\n label: z.string().optional(),\n /** Alternative text for vocalization when the button is activated. */\n vocalization: z.string().optional(),\n /** Identifier of the image associated with the button. */\n image_id: OBFOptionalIDSchema,\n /** Identifier of the sound associated with the button. */\n sound_id: OBFOptionalIDSchema,\n /**\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 /** Background color of the button in `rgb` or `rgba` format. */\n background_color: z.string().optional(),\n /** Border color of the button in `rgb` or `rgba` format. */\n border_color: z.string().optional(),\n /** Vertical position for absolute positioning (0.0 to 1.0). */\n top: z.number().min(0).max(1).optional(),\n /** Horizontal position for absolute positioning (0.0 to 1.0). */\n left: z.number().min(0).max(1).optional(),\n /** Width of the button for absolute positioning (0.0 to 1.0). */\n width: z.number().min(0).max(1).optional(),\n /** Height of the button for absolute positioning (0.0 to 1.0). */\n height: z.number().min(0).max(1).optional(),\n});\n\n/**\n * Interactive element on a board, optionally linked to images, sounds, and\n * actions. See {@link OBFButtonSchema}.\n */\nexport type OBFButton = z.infer<typeof OBFButtonSchema>;\n\n/**\n * Row-and-column layout that arranges buttons by their IDs.\n */\nexport const OBFGridSchema = z\n .looseObject({\n /** Number of rows in the grid. */\n rows: z.number().int().min(1),\n /** Number of columns in the grid. */\n columns: z.number().int().min(1),\n /**\n * 2D array representing the order of buttons by their IDs.\n * Each sub-array corresponds to a row, and each element is a button ID or null for empty slots.\n */\n order: z.array(z.array(z.union([OBFIDSchema, z.null()]))),\n })\n .refine((g) => g.order.length === g.rows, {\n message: \"Grid order length must match rows\",\n })\n .refine((g) => g.order.every((row) => row.length === g.columns), {\n message: \"Each grid row must have length equal to columns\",\n });\n\n/**\n * Row-and-column layout that arranges buttons by their IDs.\n * See {@link OBFGridSchema}.\n */\nexport type OBFGrid = z.infer<typeof OBFGridSchema>;\n\n/**\n * Root object of an `.obf` file: the complete definition of a single communication board.\n */\nexport const OBFBoardSchema = z.looseObject({\n /** Format version of the Open Board Format, e.g., `open-board-0.1`. */\n format: OBFFormatVersionSchema,\n /** Unique identifier for the board. */\n id: OBFIDSchema,\n /** Locale of the board as a BCP 47 language tag, e.g., `en`, `en-US`. */\n locale: OBFLocaleCodeSchema.optional(),\n /** List of buttons on the board. */\n buttons: z.array(OBFButtonSchema),\n /** URL where the board can be accessed or downloaded. */\n url: OBFOptionalUrlSchema,\n /** Name of the board. */\n name: z.string().optional(),\n /** Description of the board in HTML format. */\n description_html: z.string().optional(),\n /** Grid layout information for arranging buttons. */\n grid: OBFGridSchema,\n /** List of images used in the board. */\n images: z.array(OBFImageSchema).optional(),\n /** List of sounds used in the board. */\n sounds: z.array(OBFSoundSchema).optional(),\n /** Licensing information for the board. */\n license: OBFLicenseSchema.optional(),\n /** String translations for multiple locales. */\n strings: OBFStringsSchema.optional(),\n});\n\n/**\n * The complete definition of a single communication board — root object of\n * an `.obf` file. See {@link OBFBoardSchema}.\n */\nexport type OBFBoard = z.infer<typeof OBFBoardSchema>;\n\n/**\n * Table of contents for an `.obz` package, mapping resource IDs to their archive paths.\n */\nexport const OBFManifestSchema = z\n .looseObject({\n /** Format version of the Open Board Format, e.g., `open-board-0.1`. */\n format: OBFFormatVersionSchema,\n /** Path to the root board within the `.obz` package. */\n root: z.string(),\n /** Mapping of IDs to paths for boards, images, and sounds. */\n paths: z.looseObject({\n /** Mapping of board IDs to their file paths. */\n boards: z.record(z.string(), z.string()),\n /** Mapping of image IDs to their file paths. */\n images: z.record(z.string(), z.string()),\n /** Mapping of sound IDs to their file paths. */\n sounds: z.record(z.string(), z.string()).optional(),\n }),\n })\n .refine((m) => Object.values(m.paths.boards).includes(m.root), {\n message: \"root must be listed in paths.boards\",\n path: [\"root\"],\n });\n\n/**\n * Table of contents for an `.obz` package, mapping resource IDs to their\n * archive paths. See {@link OBFManifestSchema}.\n */\nexport type OBFManifest = z.infer<typeof OBFManifestSchema>;\n","/**\n * Parsing, validation, and serialization for single `.obf` board files.\n */\n\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 * Build a descriptive JSON parse-failure message, preserving the engine's\n * reason when available.\n *\n * @internal Exported for reuse by the OBZ module — not part of the public API.\n */\nexport function buildJsonParseErrorMessage(\n label: string,\n error: unknown,\n): string {\n const reason = error instanceof Error ? error.message : \"\";\n return reason\n ? `Invalid ${label}: JSON parse failed — ${reason}`\n : `Invalid ${label}: JSON parse failed`;\n}\n\n/**\n * Parse a JSON string into a validated OBF board.\n *\n * Strips an optional UTF-8 BOM prefix before parsing and throws a\n * descriptive error if the input is malformed or fails schema validation.\n *\n * @param json - The JSON string to parse.\n * @returns The validated board object.\n *\n * @throws {Error} If the JSON is malformed or 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 Error(buildJsonParseErrorMessage(\"OBF\", error), { cause: error });\n }\n\n return validateOBF(rawBoard);\n}\n\n/**\n * Read a `File` and parse its contents as a validated OBF board.\n *\n * This relies on the browser `File` API; for Node environments,\n * read the file to a string and pass it to {@link parseOBF} instead.\n *\n * @param file - A `File` handle pointing to an `.obf` file.\n * @returns The validated board object.\n *\n * @throws {Error} If the file content is malformed or fails schema validation.\n */\nexport async function loadOBF(file: File): Promise<OBFBoard> {\n const json = await file.text();\n return parseOBF(json);\n}\n\n/**\n * Validate an unknown value against the OBF board schema.\n *\n * @param data - The value to validate.\n * @returns The validated board object.\n *\n * @throws {Error} If the value fails schema validation.\n */\nexport function validateOBF(data: unknown): OBFBoard {\n const result = OBFBoardSchema.safeParse(data);\n\n if (!result.success) {\n throw new Error(`Invalid OBF: ${result.error.message}`);\n }\n\n return result.data;\n}\n\n/**\n * Stringify an OBF board to a pretty-printed JSON string.\n *\n * @param board - The board to stringify.\n * @returns A JSON string with two-space indentation.\n */\nexport function stringifyOBF(board: OBFBoard): string {\n return JSON.stringify(board, null, 2);\n}\n","/**\n * Minimal ZIP helpers over fflate: signature sniffing, unzip, and zip.\n */\n\nimport { unzip as fflateUnzip, zip as fflateZip } from \"fflate\";\n\n/**\n * First two bytes of every ZIP archive — the ASCII letters `PK`,\n * after Phil Katz, creator of the format.\n *\n * Only the 2-byte prefix is checked intentionally: this keeps the\n * test lightweight and sufficient for distinguishing ZIP from JSON.\n */\nconst ZIP_MAGIC = [0x50, 0x4b] as const;\n\n/** Balanced speed-vs-size deflate level, on fflate's 0–9 scale (0 = store). */\nconst COMPRESSION_LEVEL = 6;\n\n/**\n * Decompress a ZIP archive into a map of file paths to raw bytes.\n *\n * @param archive - The ZIP archive as an `ArrayBuffer`.\n * @returns A map of file paths to their decompressed content.\n *\n * @throws {Error} If the archive is corrupt or cannot be decompressed.\n */\nexport function unzip(archive: ArrayBuffer): Promise<Map<string, Uint8Array>> {\n return new Promise((resolve, reject) => {\n const compressed = new Uint8Array(archive);\n\n fflateUnzip(compressed, (error, entries) => {\n if (error) {\n reject(new Error(`Failed to unzip: ${error.message ?? String(error)}`));\n return;\n }\n\n const pathToBytes = new Map<string, Uint8Array>(Object.entries(entries));\n\n resolve(pathToBytes);\n });\n });\n}\n\n/**\n * Compress a map of file paths and contents into a single ZIP archive.\n *\n * Accepts both `Uint8Array` and `ArrayBuffer` values so callers can\n * pass the output of {@link unzip} directly or supply raw `ArrayBuffer`s\n * without converting first.\n *\n * @param entries - A map of file paths to their content bytes.\n * @returns The compressed archive as a `Uint8Array`.\n *\n * @throws {Error} If fflate fails to 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 if (error) {\n reject(new Error(`Failed to zip: ${error.message ?? String(error)}`));\n return;\n }\n\n resolve(result);\n });\n });\n}\n\n/**\n * Test whether an `ArrayBuffer` begins with the two-byte ZIP magic\n * prefix (`PK`).\n *\n * @param archive - The buffer to inspect.\n * @returns `true` if the buffer starts with the ZIP signature.\n */\nexport function isZip(archive: ArrayBuffer): boolean {\n const bytes = new Uint8Array(archive);\n\n return (\n bytes.length >= ZIP_MAGIC.length &&\n ZIP_MAGIC.every((byte, index) => bytes[index] === byte)\n );\n}\n","/**\n * Creation and extraction of `.obz` board packages.\n */\n\nimport { buildJsonParseErrorMessage, parseOBF } from \"./obf\";\nimport type { OBFBoard, OBFManifest } from \"./schema\";\nimport { OBFBoardSchema, OBFManifestSchema } from \"./schema\";\nimport { isZip, unzip, zip } from \"./zip\";\n\n/**\n * Fully extracted contents of an `.obz` archive.\n */\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 * This relies on the browser `File` API; for Node environments,\n * read the file to an `ArrayBuffer` and pass it to {@link extractOBZ} instead.\n *\n * @param file - A `File` handle pointing to an `.obz` archive.\n * @returns The parsed manifest, boards, root board, and binary resources.\n *\n * @throws {Error} Same failures as {@link extractOBZ}, which this delegates to.\n */\nexport async function loadOBZ(file: File): Promise<ParsedOBZ> {\n const archive = await file.arrayBuffer();\n return extractOBZ(archive);\n}\n\n/**\n * Decompress an OBZ archive and return its manifest, boards, and resources.\n *\n * @param archive - The OBZ archive as an `ArrayBuffer`.\n * @returns The parsed manifest, a map of board IDs to validated boards,\n * the resolved root board, and a map of file paths to their\n * binary content.\n *\n * @throws {Error} If the archive is not a valid ZIP, the manifest is missing,\n * a board declared in the manifest is missing or fails validation, or a\n * board's `id` differs from the ID the manifest declares for it.\n */\nexport async function extractOBZ(archive: ArrayBuffer): Promise<ParsedOBZ> {\n if (!isZip(archive)) {\n throw new Error(\"Invalid OBZ: not a ZIP file\");\n }\n\n const entries = await unzip(archive);\n\n const manifest = extractManifest(entries);\n const { boards, rootBoard } = extractBoards(manifest, entries);\n\n return { manifest, boards, rootBoard, resources: entries };\n}\n\n/**\n * Parse and validate an OBZ manifest — the table of contents that maps\n * board IDs to their file paths within the archive.\n *\n * @param json - A JSON string representing the manifest.\n * @returns The validated manifest object.\n *\n * @throws {Error} If the JSON is malformed or fails schema validation.\n */\nexport function parseManifest(json: string): OBFManifest {\n let data: unknown;\n\n try {\n data = JSON.parse(json) as unknown;\n } catch (error) {\n throw new Error(buildJsonParseErrorMessage(\"manifest\", error), {\n cause: error,\n });\n }\n\n const result = OBFManifestSchema.safeParse(data);\n\n if (!result.success) {\n throw new Error(`Invalid manifest: ${result.error.message}`);\n }\n\n return result.data;\n}\n\n/**\n * Bundle boards and optional resources into a compressed OBZ archive.\n *\n * A manifest is generated automatically from the supplied boards,\n * using the `rootBoardId` to designate the entry-point board.\n *\n * @param boards - The boards to include in the archive.\n * @param rootBoardId - The ID of the board that serves as the archive's entry point.\n * @param resources - Optional map of file paths to binary content (images, sounds, etc.).\n * @returns A `Blob` containing the compressed OBZ archive.\n *\n * @throws {Error} If `rootBoardId` does not match any of the supplied boards.\n * @throws {Error} If two supplied boards share the same ID.\n * @throws {Error} If a supplied board fails schema validation.\n * @throws {Error} If two boards declare the same media ID with conflicting paths.\n * @throws {Error} If a board declares an image or sound `path` with no matching entry in `resources`.\n * @throws {Error} If a `resources` entry would overwrite the generated `manifest.json` or a board file.\n */\nexport async function createOBZ(\n boards: OBFBoard[],\n rootBoardId: string,\n resources?: Map<string, Uint8Array | ArrayBuffer>,\n): Promise<Blob> {\n if (!boards.some((board) => board.id === rootBoardId)) {\n throw new Error(\n `Invalid OBZ: rootBoardId \"${rootBoardId}\" does not match any supplied board`,\n );\n }\n\n const seenBoardIds = new Set<string>();\n for (const board of boards) {\n if (seenBoardIds.has(board.id)) {\n throw new Error(\n `Invalid OBZ: duplicate board id \"${board.id}\" — board ids must be unique within a package`,\n );\n }\n seenBoardIds.add(board.id);\n }\n\n const entries = new Map<string, Uint8Array | ArrayBuffer>();\n\n const boardPaths = Object.fromEntries(\n boards.map((board) => [board.id, `boards/${board.id}.obf`]),\n );\n\n const imagePaths = collectMediaPaths(boards, \"images\");\n const soundPaths = collectMediaPaths(boards, \"sounds\");\n\n const manifestResult = OBFManifestSchema.safeParse({\n format: \"open-board-0.1\",\n root: `boards/${rootBoardId}.obf`,\n paths: {\n boards: boardPaths,\n images: imagePaths,\n ...(Object.keys(soundPaths).length > 0 ? { sounds: soundPaths } : {}),\n },\n });\n\n if (!manifestResult.success) {\n throw new Error(\n `Invalid OBZ: generated manifest failed validation — ${manifestResult.error.message}`,\n );\n }\n\n const manifest = manifestResult.data;\n\n const encoder = new TextEncoder();\n\n entries.set(\n \"manifest.json\",\n encoder.encode(JSON.stringify(manifest, null, 2)),\n );\n\n for (const board of boards) {\n const result = OBFBoardSchema.safeParse(board);\n if (!result.success) {\n throw new Error(\n `Invalid OBZ: board \"${board.id}\" failed validation — ${result.error.message}`,\n );\n }\n const path = `boards/${result.data.id}.obf`;\n entries.set(path, encoder.encode(JSON.stringify(result.data, null, 2)));\n }\n\n if (resources) {\n for (const [path, bytes] of resources) {\n if (entries.has(path)) {\n throw new Error(\n `Invalid OBZ: resource path \"${path}\" collides with a generated board or manifest entry`,\n );\n }\n entries.set(path, bytes);\n }\n }\n\n assertPathsPresent(\"image\", imagePaths, entries);\n assertPathsPresent(\"sound\", soundPaths, entries);\n\n const compressed = await zip(entries);\n return new Blob([compressed], { type: \"application/zip\" });\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walk every board's media collection and produce the `{ id -> path }` map\n * the spec calls \"redundant but still required\" for the OBZ manifest.\n *\n * Throws when two boards declare the same media ID with conflicting paths\n * — a silent OBZ that points at a non-existent file is worse than a clear error.\n */\nfunction collectMediaPaths(\n boards: OBFBoard[],\n kind: \"images\" | \"sounds\",\n): Record<string, string> {\n const paths: Record<string, string> = {};\n\n for (const board of boards) {\n for (const media of board[kind] ?? []) {\n if (media.path === undefined) {\n continue;\n }\n\n const existing = paths[media.id];\n if (existing !== undefined && existing !== media.path) {\n throw new Error(\n `Invalid OBZ: ${kind} id \"${media.id}\" maps to conflicting paths \"${existing}\" and \"${media.path}\"`,\n );\n }\n paths[media.id] = media.path;\n }\n }\n\n return paths;\n}\n\n/**\n * Assert that every media path the generated manifest declares exists as an\n * archive entry — the same contract {@link extractOBZ} assumes when reading.\n *\n * Only media that declared a `path` reach this check, so `url`/`data`-only\n * media are never flagged.\n */\nfunction assertPathsPresent(\n kind: \"image\" | \"sound\",\n paths: Record<string, string>,\n entries: Map<string, Uint8Array | ArrayBuffer>,\n): void {\n for (const [id, path] of Object.entries(paths)) {\n if (!entries.has(path)) {\n throw new Error(\n `Invalid OBZ: ${kind} \"${id}\" references \"${path}\" but no matching resource was supplied`,\n );\n }\n }\n}\n\nfunction extractManifest(entries: Map<string, Uint8Array>): OBFManifest {\n const manifestBytes = entries.get(\"manifest.json\");\n\n if (!manifestBytes) {\n throw new Error(\"Invalid OBZ: missing manifest.json\");\n }\n\n const manifestJson = new TextDecoder().decode(manifestBytes);\n return parseManifest(manifestJson);\n}\n\nfunction extractBoards(\n manifest: OBFManifest,\n entries: Map<string, Uint8Array>,\n): { boards: Map<string, OBFBoard>; rootBoard: OBFBoard } {\n const boards = new Map<string, OBFBoard>();\n let rootBoard: OBFBoard | undefined;\n\n for (const [id, path] of Object.entries(manifest.paths.boards)) {\n const boardBytes = entries.get(path);\n\n if (!boardBytes) {\n throw new Error(\n `Invalid OBZ: board \"${id}\" declared in manifest but missing at path \"${path}\"`,\n );\n }\n\n const boardJson = new TextDecoder().decode(boardBytes);\n const board = parseOBF(boardJson);\n\n if (board.id !== id) {\n throw new Error(\n `Invalid OBZ: board at \"${path}\" has id \"${board.id}\" but the manifest declares it as \"${id}\"`,\n );\n }\n\n boards.set(id, board);\n\n if (path === manifest.root) {\n rootBoard = board;\n }\n }\n\n if (!rootBoard) {\n // Unreachable for validated manifests: the schema requires `root` to be\n // listed in `paths.boards`. Kept as a guard for hand-built manifests.\n throw new Error(\n `Invalid OBZ: root board \"${manifest.root}\" not found in paths.boards`,\n );\n }\n\n return { boards, rootBoard };\n}\n","/**\n * Format-agnostic loading of `.obf` boards and `.obz` packages.\n */\n\nimport { parseOBF } from \"./obf\";\nimport { extractOBZ } from \"./obz\";\nimport type { ParsedOBZ } from \"./obz\";\nimport type { OBFBoard } from \"./schema\";\nimport { isZip } from \"./zip\";\n\n/**\n * Result of {@link loadBoard} — a discriminated union over the two file\n * shapes the Open Board Format defines.\n *\n * Switch on `format` to narrow:\n *\n * ```ts\n * const loaded = await loadBoard(file);\n * if (loaded.format === \"obz\") {\n * loaded.archive.rootBoard; // home board of the ParsedOBZ archive\n * } else {\n * loaded.board; // OBFBoard\n * }\n * ```\n */\nexport type LoadedBoard =\n | { format: \"obz\"; archive: ParsedOBZ }\n | { format: \"obf\"; board: OBFBoard };\n\n/**\n * Detect whether the input is a single OBF board or an OBZ package and load it\n * accordingly.\n *\n * 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` handle or `ArrayBuffer` holding `.obf` or `.obz` content.\n * @returns A discriminated union tagged by `format`.\n *\n * @throws {Error} If an OBZ archive is malformed or its manifest is missing,\n * or if an OBF board is malformed or fails schema validation.\n */\nexport async function loadBoard(\n input: File | ArrayBuffer,\n): Promise<LoadedBoard> {\n const buffer =\n input instanceof ArrayBuffer ? input : await input.arrayBuffer();\n\n if (isZip(buffer)) {\n return { format: \"obz\", archive: await extractOBZ(buffer) };\n }\n\n return { format: \"obf\", board: parseOBF(new TextDecoder().decode(buffer)) };\n}\n"],"mappings":";;;;;;;;;;;;AAYA,MAAM,uBAAuB,EAC1B,MAAM,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,CAC/B,WAAW,QAAS,QAAQ,KAAK,KAAA,IAAY,GAAI,CAAC,CAClD,SAAS;;AAGZ,MAAM,yBAAyB,EAC5B,MAAM,CAAC,EAAE,MAAM,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,CACjC,WAAW,QAAS,QAAQ,KAAK,KAAA,IAAY,GAAI,CAAC,CAClD,SAAS;;AAGZ,MAAM,sBAAsB,EACzB,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAC/B,WAAW,QAAQ;CAClB,MAAM,MAAM,OAAO,GAAG;CACtB,OAAO,QAAQ,KAAK,KAAA,IAAY;AAClC,CAAC,CAAC,CACD,SAAS;;AAGZ,MAAa,cAAc,EACxB,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAC/B,WAAW,QAAQ,OAAO,GAAG,CAAC,CAAC,CAC/B,KAAK,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC;;AASzB,MAAa,yBAAyB,EAAE,OAAO,CAAC,CAAC,MAAM,iBAAiB;;;;;AAYxE,MAAa,sBAAsB,EAAE,OAAO;;;;;AAY5C,MAAa,4BAA4B,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC;;;;;AAYxE,MAAa,mBAAmB,EAAE,OAAO,EAAE,OAAO,GAAG,yBAAyB;;;;;AAY9E,MAAa,0BAA0B,EAAE,OAAO,CAAC,CAAC,MAAM,QAAQ;;;;;AAYhE,MAAa,2BAA2B,EACrC,OAAO,CAAC,CACR,MAAM,sBAAsB;;AAS/B,MAAa,wBAAwB,EAAE,MAAM,CAC3C,yBACA,wBACF,CAAC;;AASD,MAAa,mBAAmB,EAAE,YAAY;;CAE5C,MAAM,EAAE,OAAO;;CAEf,sBAAsB;;CAEtB,YAAY;;CAEZ,aAAa,EAAE,OAAO,CAAC,CAAC,SAAS;;CAEjC,YAAY;;CAEZ,cAAc;AAChB,CAAC;;;;;;;;;;;;;AAoBD,MAAa,iBAAiB,EAAE,YAAY;;CAE1C,IAAI;;CAEJ,MAAM,EAAE,OAAO,CAAC,CAAC,SAAS;;CAE1B,MAAM,EAAE,OAAO,CAAC,CAAC,SAAS;;;;;CAK1B,UAAU;;CAEV,KAAK;;CAEL,cAAc,EAAE,OAAO,CAAC,CAAC,SAAS;;CAElC,SAAS,iBAAiB,SAAS;AACrC,CAAC;;AASD,MAAa,sBAAsB,EAAE,YAAY;;CAE/C,KAAK,EAAE,OAAO;;CAEd,UAAU,EAAE,OAAO;AACrB,CAAC;;;;;;;;;;;AAkBD,MAAa,iBAAiB,eAAe,OAAO;;CAElD,QAAQ,oBAAoB,SAAS;;CAErC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;;CAE3B,QAAQ,EAAE,OAAO,CAAC,CAAC,SAAS;AAC9B,CAAC;;;;AAWD,MAAa,iBAAiB;;AAS9B,MAAa,qBAAqB,EAAE,YAAY;;CAE9C,IAAI;;CAEJ,MAAM,EAAE,OAAO,CAAC,CAAC,SAAS;;;;;CAK1B,UAAU;;CAEV,KAAK;;CAEL,MAAM,EAAE,OAAO,CAAC,CAAC,SAAS;AAC5B,CAAC;;;;AAWD,MAAa,kBAAkB,EAAE,YAAY;;CAE3C,IAAI;;CAEJ,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;;CAE3B,cAAc,EAAE,OAAO,CAAC,CAAC,SAAS;;CAElC,UAAU;;CAEV,UAAU;;;;;CAKV,QAAQ,sBAAsB,SAAS;;;;;CAKvC,SAAS,EAAE,MAAM,qBAAqB,CAAC,CAAC,SAAS;;CAEjD,YAAY,mBAAmB,SAAS;;CAExC,kBAAkB,EAAE,OAAO,CAAC,CAAC,SAAS;;CAEtC,cAAc,EAAE,OAAO,CAAC,CAAC,SAAS;;CAElC,KAAK,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;;CAEvC,MAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;;CAExC,OAAO,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;;CAEzC,QAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;AAC5C,CAAC;;;;AAWD,MAAa,gBAAgB,EAC1B,YAAY;;CAEX,MAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;;CAE5B,SAAS,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;;;;;CAK/B,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;AAC1D,CAAC,CAAC,CACD,QAAQ,MAAM,EAAE,MAAM,WAAW,EAAE,MAAM,EACxC,SAAS,oCACX,CAAC,CAAC,CACD,QAAQ,MAAM,EAAE,MAAM,OAAO,QAAQ,IAAI,WAAW,EAAE,OAAO,GAAG,EAC/D,SAAS,kDACX,CAAC;;;;AAWH,MAAa,iBAAiB,EAAE,YAAY;;CAE1C,QAAQ;;CAER,IAAI;;CAEJ,QAAQ,oBAAoB,SAAS;;CAErC,SAAS,EAAE,MAAM,eAAe;;CAEhC,KAAK;;CAEL,MAAM,EAAE,OAAO,CAAC,CAAC,SAAS;;CAE1B,kBAAkB,EAAE,OAAO,CAAC,CAAC,SAAS;;CAEtC,MAAM;;CAEN,QAAQ,EAAE,MAAM,cAAc,CAAC,CAAC,SAAS;;CAEzC,QAAQ,EAAE,MAAM,cAAc,CAAC,CAAC,SAAS;;CAEzC,SAAS,iBAAiB,SAAS;;CAEnC,SAAS,iBAAiB,SAAS;AACrC,CAAC;;;;AAWD,MAAa,oBAAoB,EAC9B,YAAY;;CAEX,QAAQ;;CAER,MAAM,EAAE,OAAO;;CAEf,OAAO,EAAE,YAAY;;EAEnB,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC;;EAEvC,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC;;EAEvC,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,SAAS;CACpD,CAAC;AACH,CAAC,CAAC,CACD,QAAQ,MAAM,OAAO,OAAO,EAAE,MAAM,MAAM,CAAC,CAAC,SAAS,EAAE,IAAI,GAAG;CAC7D,SAAS;CACT,MAAM,CAAC,MAAM;AACf,CAAC;;;ACjYH,MAAM,WAAW;;AAGjB,SAAS,SAAS,MAAsB;CACtC,OAAO,KAAK,WAAW,QAAQ,IAAI,KAAK,MAAM,CAAC,IAAI;AACrD;;;;;;;AAQA,SAAgB,2BACd,OACA,OACQ;CACR,MAAM,SAAS,iBAAiB,QAAQ,MAAM,UAAU;CACxD,OAAO,SACH,WAAW,MAAM,wBAAwB,WACzC,WAAW,MAAM;AACvB;;;;;;;;;;;;AAaA,SAAgB,SAAS,MAAwB;CAC/C,MAAM,YAAY,SAAS,IAAI;CAE/B,IAAI;CAEJ,IAAI;EACF,WAAW,KAAK,MAAM,SAAS;CACjC,SAAS,OAAO;EACd,MAAM,IAAI,MAAM,2BAA2B,OAAO,KAAK,GAAG,EAAE,OAAO,MAAM,CAAC;CAC5E;CAEA,OAAO,YAAY,QAAQ;AAC7B;;;;;;;;;;;;AAaA,eAAsB,QAAQ,MAA+B;CAE3D,OAAO,SAAS,MADG,KAAK,KAAK,CACT;AACtB;;;;;;;;;AAUA,SAAgB,YAAY,MAAyB;CACnD,MAAM,SAAS,eAAe,UAAU,IAAI;CAE5C,IAAI,CAAC,OAAO,SACV,MAAM,IAAI,MAAM,gBAAgB,OAAO,MAAM,SAAS;CAGxD,OAAO,OAAO;AAChB;;;;;;;AAQA,SAAgB,aAAa,OAAyB;CACpD,OAAO,KAAK,UAAU,OAAO,MAAM,CAAC;AACtC;;;;;;;;;;;;;ACpFA,MAAM,YAAY,CAAC,IAAM,EAAI;;AAG7B,MAAM,oBAAoB;;;;;;;;;AAU1B,SAAgB,MAAM,SAAwD;CAC5E,OAAO,IAAI,SAAS,SAAS,WAAW;EAGtC,QAAY,IAFW,WAAW,OAEb,IAAI,OAAO,YAAY;GAC1C,IAAI,OAAO;IACT,uBAAO,IAAI,MAAM,oBAAoB,MAAM,WAAW,OAAO,KAAK,GAAG,CAAC;IACtE;GACF;GAIA,QAAQ,IAFgB,IAAwB,OAAO,QAAQ,OAAO,CAEpD,CAAC;EACrB,CAAC;CACH,CAAC;AACH;;;;;;;;;;;;;AAcA,SAAgB,IACd,SACqB;CACrB,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,cAA0C,CAAC;EAEjD,KAAK,MAAM,CAAC,MAAM,YAAY,SAC5B,YAAY,QACV,mBAAmB,aAAa,UAAU,IAAI,WAAW,OAAO;EAGpE,MAAU,aAAa,EAAE,OAAO,kBAAkB,IAAI,OAAO,WAAW;GACtE,IAAI,OAAO;IACT,uBAAO,IAAI,MAAM,kBAAkB,MAAM,WAAW,OAAO,KAAK,GAAG,CAAC;IACpE;GACF;GAEA,QAAQ,MAAM;EAChB,CAAC;CACH,CAAC;AACH;;;;;;;;AASA,SAAgB,MAAM,SAA+B;CACnD,MAAM,QAAQ,IAAI,WAAW,OAAO;CAEpC,OACE,MAAM,UAAU,UAAU,UAC1B,UAAU,OAAO,MAAM,UAAU,MAAM,WAAW,IAAI;AAE1D;;;;;;;;;;;;;;;;;AClDA,eAAsB,QAAQ,MAAgC;CAE5D,OAAO,WAAW,MADI,KAAK,YAAY,CACd;AAC3B;;;;;;;;;;;;;AAcA,eAAsB,WAAW,SAA0C;CACzE,IAAI,CAAC,MAAM,OAAO,GAChB,MAAM,IAAI,MAAM,6BAA6B;CAG/C,MAAM,UAAU,MAAM,MAAM,OAAO;CAEnC,MAAM,WAAW,gBAAgB,OAAO;CACxC,MAAM,EAAE,QAAQ,cAAc,cAAc,UAAU,OAAO;CAE7D,OAAO;EAAE;EAAU;EAAQ;EAAW,WAAW;CAAQ;AAC3D;;;;;;;;;;AAWA,SAAgB,cAAc,MAA2B;CACvD,IAAI;CAEJ,IAAI;EACF,OAAO,KAAK,MAAM,IAAI;CACxB,SAAS,OAAO;EACd,MAAM,IAAI,MAAM,2BAA2B,YAAY,KAAK,GAAG,EAC7D,OAAO,MACT,CAAC;CACH;CAEA,MAAM,SAAS,kBAAkB,UAAU,IAAI;CAE/C,IAAI,CAAC,OAAO,SACV,MAAM,IAAI,MAAM,qBAAqB,OAAO,MAAM,SAAS;CAG7D,OAAO,OAAO;AAChB;;;;;;;;;;;;;;;;;;;AAoBA,eAAsB,UACpB,QACA,aACA,WACe;CACf,IAAI,CAAC,OAAO,MAAM,UAAU,MAAM,OAAO,WAAW,GAClD,MAAM,IAAI,MACR,6BAA6B,YAAY,oCAC3C;CAGF,MAAM,+BAAe,IAAI,IAAY;CACrC,KAAK,MAAM,SAAS,QAAQ;EAC1B,IAAI,aAAa,IAAI,MAAM,EAAE,GAC3B,MAAM,IAAI,MACR,oCAAoC,MAAM,GAAG,8CAC/C;EAEF,aAAa,IAAI,MAAM,EAAE;CAC3B;CAEA,MAAM,0BAAU,IAAI,IAAsC;CAE1D,MAAM,aAAa,OAAO,YACxB,OAAO,KAAK,UAAU,CAAC,MAAM,IAAI,UAAU,MAAM,GAAG,KAAK,CAAC,CAC5D;CAEA,MAAM,aAAa,kBAAkB,QAAQ,QAAQ;CACrD,MAAM,aAAa,kBAAkB,QAAQ,QAAQ;CAErD,MAAM,iBAAiB,kBAAkB,UAAU;EACjD,QAAQ;EACR,MAAM,UAAU,YAAY;EAC5B,OAAO;GACL,QAAQ;GACR,QAAQ;GACR,GAAI,OAAO,KAAK,UAAU,CAAC,CAAC,SAAS,IAAI,EAAE,QAAQ,WAAW,IAAI,CAAC;EACrE;CACF,CAAC;CAED,IAAI,CAAC,eAAe,SAClB,MAAM,IAAI,MACR,uDAAuD,eAAe,MAAM,SAC9E;CAGF,MAAM,WAAW,eAAe;CAEhC,MAAM,UAAU,IAAI,YAAY;CAEhC,QAAQ,IACN,iBACA,QAAQ,OAAO,KAAK,UAAU,UAAU,MAAM,CAAC,CAAC,CAClD;CAEA,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,SAAS,eAAe,UAAU,KAAK;EAC7C,IAAI,CAAC,OAAO,SACV,MAAM,IAAI,MACR,uBAAuB,MAAM,GAAG,wBAAwB,OAAO,MAAM,SACvE;EAEF,MAAM,OAAO,UAAU,OAAO,KAAK,GAAG;EACtC,QAAQ,IAAI,MAAM,QAAQ,OAAO,KAAK,UAAU,OAAO,MAAM,MAAM,CAAC,CAAC,CAAC;CACxE;CAEA,IAAI,WACF,KAAK,MAAM,CAAC,MAAM,UAAU,WAAW;EACrC,IAAI,QAAQ,IAAI,IAAI,GAClB,MAAM,IAAI,MACR,+BAA+B,KAAK,oDACtC;EAEF,QAAQ,IAAI,MAAM,KAAK;CACzB;CAGF,mBAAmB,SAAS,YAAY,OAAO;CAC/C,mBAAmB,SAAS,YAAY,OAAO;CAE/C,MAAM,aAAa,MAAM,IAAI,OAAO;CACpC,OAAO,IAAI,KAAK,CAAC,UAAU,GAAG,EAAE,MAAM,kBAAkB,CAAC;AAC3D;;;;;;;;AAaA,SAAS,kBACP,QACA,MACwB;CACxB,MAAM,QAAgC,CAAC;CAEvC,KAAK,MAAM,SAAS,QAClB,KAAK,MAAM,SAAS,MAAM,SAAS,CAAC,GAAG;EACrC,IAAI,MAAM,SAAS,KAAA,GACjB;EAGF,MAAM,WAAW,MAAM,MAAM;EAC7B,IAAI,aAAa,KAAA,KAAa,aAAa,MAAM,MAC/C,MAAM,IAAI,MACR,gBAAgB,KAAK,OAAO,MAAM,GAAG,+BAA+B,SAAS,SAAS,MAAM,KAAK,EACnG;EAEF,MAAM,MAAM,MAAM,MAAM;CAC1B;CAGF,OAAO;AACT;;;;;;;;AASA,SAAS,mBACP,MACA,OACA,SACM;CACN,KAAK,MAAM,CAAC,IAAI,SAAS,OAAO,QAAQ,KAAK,GAC3C,IAAI,CAAC,QAAQ,IAAI,IAAI,GACnB,MAAM,IAAI,MACR,gBAAgB,KAAK,IAAI,GAAG,gBAAgB,KAAK,wCACnD;AAGN;AAEA,SAAS,gBAAgB,SAA+C;CACtE,MAAM,gBAAgB,QAAQ,IAAI,eAAe;CAEjD,IAAI,CAAC,eACH,MAAM,IAAI,MAAM,oCAAoC;CAItD,OAAO,cADc,IAAI,YAAY,CAAC,CAAC,OAAO,aACd,CAAC;AACnC;AAEA,SAAS,cACP,UACA,SACwD;CACxD,MAAM,yBAAS,IAAI,IAAsB;CACzC,IAAI;CAEJ,KAAK,MAAM,CAAC,IAAI,SAAS,OAAO,QAAQ,SAAS,MAAM,MAAM,GAAG;EAC9D,MAAM,aAAa,QAAQ,IAAI,IAAI;EAEnC,IAAI,CAAC,YACH,MAAM,IAAI,MACR,uBAAuB,GAAG,8CAA8C,KAAK,EAC/E;EAIF,MAAM,QAAQ,SADI,IAAI,YAAY,CAAC,CAAC,OAAO,UACZ,CAAC;EAEhC,IAAI,MAAM,OAAO,IACf,MAAM,IAAI,MACR,0BAA0B,KAAK,YAAY,MAAM,GAAG,qCAAqC,GAAG,EAC9F;EAGF,OAAO,IAAI,IAAI,KAAK;EAEpB,IAAI,SAAS,SAAS,MACpB,YAAY;CAEhB;CAEA,IAAI,CAAC,WAGH,MAAM,IAAI,MACR,4BAA4B,SAAS,KAAK,4BAC5C;CAGF,OAAO;EAAE;EAAQ;CAAU;AAC7B;;;;;;;;;;;;;;;;;;;;;;AC1QA,eAAsB,UACpB,OACsB;CACtB,MAAM,SACJ,iBAAiB,cAAc,QAAQ,MAAM,MAAM,YAAY;CAEjE,IAAI,MAAM,MAAM,GACd,OAAO;EAAE,QAAQ;EAAO,SAAS,MAAM,WAAW,MAAM;CAAE;CAG5D,OAAO;EAAE,QAAQ;EAAO,OAAO,SAAS,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM,CAAC;CAAE;AAC5E"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shayc/open-board-format",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "A TypeScript toolkit for Open Board Format — the open standard for Augmentative and Alternative Communication (AAC) boards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shay Cojocaru <shayc@outlook.com>",
|