@shayc/open-board-format 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @shayc/open-board-format
2
2
 
3
+ ## 0.1.6
4
+
5
+ ### Patch Changes
6
+
7
+ - e31c5bd: Prefix the OBZ missing-board extraction error with `Invalid OBZ:` so it matches the convention used by every other OBZ failure, letting consumers reliably detect malformed packages by message prefix.
8
+
9
+ ## 0.1.5
10
+
11
+ ### Patch Changes
12
+
13
+ - 222eda9: Rewrite the README for clarity: sharper overview that maps each entry point to its input shape, new Errors and Security sections, and a note that validation preserves unknown fields (vendor extensions survive a parse/stringify round trip). Also sort `package.json` keywords.
14
+
3
15
  ## 0.1.4
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -1,20 +1,12 @@
1
- # @shayc/open-board-format
1
+ # `@shayc/open-board-format`
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@shayc/open-board-format)](https://www.npmjs.com/package/@shayc/open-board-format)
4
- [![license](https://img.shields.io/npm/l/@shayc/open-board-format)](LICENSE)
5
-
6
- A type-safe toolkit to parse, validate, and create Open Board Format (OBF/OBZ) files for Augmentative and Alternative Communication (AAC) applications.
7
-
8
- [Open Board Format](https://www.openboardformat.org/) is an open standard for representing AAC communication boards. It defines two file types:
9
-
10
- - **OBF** (`.obf`) — A JSON file describing a single communication board (buttons, images, sounds, grid layout, metadata).
11
- - **OBZ** (`.obz`) — A ZIP archive containing one or more `.obf` boards along with their associated media and a `manifest.json`.
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.
12
4
 
13
- ## Features
5
+ [![npm version](https://img.shields.io/npm/v/@shayc/open-board-format)](https://www.npmjs.com/package/@shayc/open-board-format)
6
+ [![CI](https://github.com/shayc/open-board-format/actions/workflows/ci.yml/badge.svg)](https://github.com/shayc/open-board-format/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/npm/l/@shayc/open-board-format.svg)](LICENSE)
14
8
 
15
- - **Parse & Validate:** Parse and validate OBF boards from JSON strings, objects, or `File` handles.
16
- - **Create & Extract:** Serialize boards to JSON, and create or extract OBZ packages (ZIP archives with boards, images, and sounds).
17
- - **Zod Schemas:** Every OBF type has a corresponding [Zod](https://zod.dev/) schema for runtime validation or API contracts, with full TypeScript types inferred directly.
9
+ OBF (`.obf`) is a JSON file describing a single communication board — buttons, images, sounds, grid layout, metadata. OBZ (`.obz`) is a ZIP archive bundling one or more `.obf` boards with their media and a `manifest.json`.
18
10
 
19
11
  ## Install
20
12
 
@@ -24,21 +16,32 @@ npm install @shayc/open-board-format
24
16
 
25
17
  ## Quick start
26
18
 
27
- ### Parse a single board (OBF)
28
-
29
19
  ```ts
30
- import { parseOBF, validateOBF, loadOBF } from "@shayc/open-board-format";
20
+ import { parseOBF } from "@shayc/open-board-format";
31
21
 
32
- // Parse from a JSON string
33
22
  const board = parseOBF(jsonString);
34
- console.log(board.name); // "My Board"
23
+ console.log(board.id, board.buttons.length);
24
+ ```
35
25
 
36
- // Validate an unknown object (throws on failure)
37
- const validated = validateOBF(untrustedData);
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).
38
27
 
39
- // Load from a browser File object
40
- const fromFile = await loadOBF(file);
41
- ```
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.
34
+
35
+ Every OBF type ships with a matching `*Schema` Zod schema (e.g. `OBFBoardSchema`, `OBFManifestSchema`), so you can validate inline with `safeParse` or wire the schema straight into an API contract — the TypeScript types are inferred from those schemas.
36
+
37
+ Validation preserves unknown fields rather than stripping them, so vendor extensions allowed by the OBF spec survive a `parseOBF` → `stringifyOBF` round trip.
38
+
39
+ ## Requirements
40
+
41
+ - **Module format:** ESM only.
42
+ - **Runtime:** browser or Node 22+ — works against `File`, `ArrayBuffer`, and `Blob`.
43
+
44
+ ## Examples
42
45
 
43
46
  ### Extract an OBZ package
44
47
 
@@ -51,7 +54,6 @@ const { manifest, boards, resources } = await loadOBZ(file);
51
54
  // Or from an ArrayBuffer (e.g. fetch response)
52
55
  const parsed = await extractOBZ(buffer);
53
56
 
54
- // Access boards and resources
55
57
  const homeBoard = parsed.boards.get("1");
56
58
  const imageBytes = parsed.resources.get("images/logo.png");
57
59
  ```
@@ -76,7 +78,7 @@ const resources = new Map([["images/logo.png", pngBytes]]);
76
78
  const blob = await createOBZ(boards, "board-1", resources);
77
79
  ```
78
80
 
79
- ### Use Zod schemas directly
81
+ ### Validate with Zod directly
80
82
 
81
83
  ```ts
82
84
  import { OBFBoardSchema } from "@shayc/open-board-format";
@@ -103,12 +105,12 @@ if (result.success) {
103
105
 
104
106
  ### OBZ (board package)
105
107
 
106
- | Function | Description |
107
- | --------------------------------------- | ------------------------------------------------------------- |
108
- | `loadOBZ(file)` | Load an OBZ package from a browser `File` |
109
- | `extractOBZ(archive)` | Extract boards, manifest, and resources from an `ArrayBuffer` |
110
- | `createOBZ(boards, rootId, resources?)` | Create an OBZ package as a `Blob` |
111
- | `parseManifest(json)` | Parse a `manifest.json` string into a validated `OBFManifest` |
108
+ | Function | Description |
109
+ | -------------------------------------------- | ------------------------------------------------------------- |
110
+ | `loadOBZ(file)` | Load an OBZ package from a browser `File` |
111
+ | `extractOBZ(archive)` | Extract boards, manifest, and resources from an `ArrayBuffer` |
112
+ | `createOBZ(boards, rootBoardId, resources?)` | Create an OBZ package as a `Blob` |
113
+ | `parseManifest(json)` | Parse a `manifest.json` string into a validated `OBFManifest` |
112
114
 
113
115
  ### Utilities
114
116
 
@@ -142,18 +144,42 @@ if (result.success) {
142
144
  | `OBFLocalizedStrings` | Key-value string translations |
143
145
  | `OBFStrings` | Multi-locale string translations |
144
146
 
145
- ## Development
147
+ ### Schemas
146
148
 
147
- ```bash
148
- npm install # Install dependencies
149
- npm test # Run tests (vitest)
150
- npm run build # Build for production (tsdown)
151
- npm run typecheck # Type-check without emitting
149
+ Every type above except `ParsedOBZ` is exported alongside a matching Zod schema with a `Schema` suffix — `OBFBoard` → `OBFBoardSchema`, `OBFManifest` → `OBFManifestSchema`, and so on. Import any of them to validate with `safeParse`/`parse` or to compose into your own schemas:
150
+
151
+ ```ts
152
+ import { OBFButtonSchema, OBFManifestSchema } from "@shayc/open-board-format";
152
153
  ```
153
154
 
155
+ ## Errors
156
+
157
+ All failures throw plain `Error`. The message identifies what failed, typically with one of these prefixes:
158
+
159
+ - `Invalid OBF:` — schema validation rejected an OBF board.
160
+ - `Invalid OBZ:` — the package was malformed (not a ZIP, missing manifest, unreadable entry).
161
+ - `Invalid manifest:` — `manifest.json` failed to parse or validate.
162
+
163
+ 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.
164
+
165
+ ## Security
166
+
167
+ OBZ archives are untrusted input. This library does not enforce limits on entry size or count, and does not sanitize entry paths — if you write extracted resources to disk, validate paths yourself first to avoid directory traversal. For stronger guarantees against zip-bomb-style payloads, run extraction in a sandboxed context (Web Worker, isolated process).
168
+
169
+ 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).
170
+
171
+ ## Versioning
172
+
173
+ Semver; see [CHANGELOG.md](CHANGELOG.md).
174
+
175
+ ## Contributing
176
+
177
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup (Node 22+, Vitest, the changeset workflow).
178
+
154
179
  ## Related
155
180
 
156
- - [Open Board Format specification](https://www.openboardformat.org/docs) — Official standard and format documentation
181
+ - [Open Board Format specification](https://www.openboardformat.org/docs) — the official standard and format documentation.
182
+ - [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)).
157
183
 
158
184
  ## License
159
185
 
package/dist/index.mjs CHANGED
@@ -495,7 +495,7 @@ function extractBoards(manifest, entries) {
495
495
  const boards = /* @__PURE__ */ new Map();
496
496
  for (const [id, path] of Object.entries(manifest.paths.boards)) {
497
497
  const boardBytes = entries.get(path);
498
- if (!boardBytes) throw new Error(`Board "${id}" declared in manifest but missing at path "${path}"`);
498
+ if (!boardBytes) throw new Error(`Invalid OBZ: board "${id}" declared in manifest but missing at path "${path}"`);
499
499
  const boardJson = new TextDecoder().decode(boardBytes);
500
500
  boards.set(id, parseOBF(boardJson));
501
501
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/schema.ts","../src/obf.ts","../src/zip.ts","../src/obz.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.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});\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 */\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 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 entries.set(path, bytes);\n }\n }\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\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 `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"],"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"],"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.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});\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 */\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 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 entries.set(path, bytes);\n }\n }\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\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"],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shayc/open-board-format",
3
- "version": "0.1.4",
4
- "description": "Parse, validate, and create Open Board Format (OBF/OBZ) files for AAC applications.",
3
+ "version": "0.1.6",
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>",
7
7
  "homepage": "https://github.com/shayc/open-board-format#readme",
@@ -14,8 +14,12 @@
14
14
  },
15
15
  "keywords": [
16
16
  "aac",
17
- "open-board-format",
18
- "communication-board"
17
+ "aac-board",
18
+ "assistive-technology",
19
+ "communication-board",
20
+ "obf",
21
+ "obz",
22
+ "open-board-format"
19
23
  ],
20
24
  "type": "module",
21
25
  "sideEffects": false,