@shayc/open-board-format 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +6 -6
- package/dist/index.d.mts +8 -7
- package/dist/index.mjs +17 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @shayc/open-board-format
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8836a93: `loadBoard` now accepts an `ArrayBuffer` in addition to a `File`, so Node and fetch-based consumers can use format detection without constructing a `File`.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 8836a93: Harden OBZ validation:
|
|
12
|
+
|
|
13
|
+
- `createOBZ` now throws on duplicate board ids instead of silently keeping only the last board with a given id.
|
|
14
|
+
- Manifest validation (`OBFManifestSchema`, `parseManifest`, `extractOBZ`) now rejects manifests whose `root` is not listed in `paths.boards`, instead of extracting an archive whose root board can't be resolved.
|
|
15
|
+
|
|
3
16
|
## 0.2.0
|
|
4
17
|
|
|
5
18
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Two file types; pick the entry point by what you have:
|
|
|
32
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
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
34
|
|
|
35
|
-
If you accept a `File` and don't know which of the two it is, use `loadBoard` — it sniffs the bytes and returns a `{ format, ... }` union so you don't have to inspect the extension yourself.
|
|
35
|
+
If you accept a `File` (or raw `ArrayBuffer`) and don't know which of the two it is, use `loadBoard` — it sniffs the bytes and returns a `{ format, ... }` union so you don't have to inspect the extension yourself.
|
|
36
36
|
|
|
37
37
|
Every OBF type ships with a matching `*Schema` Zod schema (e.g. `OBFBoardSchema`, `OBFManifestSchema`), so you can validate inline with `safeParse` or wire the schema straight into an API contract — the TypeScript types are inferred from those schemas.
|
|
38
38
|
|
|
@@ -131,9 +131,9 @@ if (result.success) {
|
|
|
131
131
|
|
|
132
132
|
### Either format
|
|
133
133
|
|
|
134
|
-
| Function
|
|
135
|
-
|
|
|
136
|
-
| `loadBoard(
|
|
134
|
+
| Function | Description |
|
|
135
|
+
| ------------------ | ------------------------------------------------------------------------------------------- |
|
|
136
|
+
| `loadBoard(input)` | Detect OBF vs OBZ from a `File` or `ArrayBuffer` and load it; returns a `LoadedBoard` union |
|
|
137
137
|
|
|
138
138
|
### Utilities
|
|
139
139
|
|
|
@@ -181,8 +181,8 @@ import { OBFButtonSchema, OBFManifestSchema } from "@shayc/open-board-format";
|
|
|
181
181
|
All failures throw plain `Error`. The message identifies what failed, typically with one of these prefixes:
|
|
182
182
|
|
|
183
183
|
- `Invalid OBF:` — schema validation rejected an OBF board.
|
|
184
|
-
- `Invalid OBZ:` — the package was rejected. On read: not a ZIP, missing manifest, or the manifest references a board file not in the archive. On write (`createOBZ`): `rootBoardId` matches no board, 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
|
-
- `Invalid manifest:` — `manifest.json` failed to parse or validate.
|
|
184
|
+
- `Invalid OBZ:` — the package was rejected. On read: not a ZIP, missing manifest, or the manifest references a board file not in the archive. On write (`createOBZ`): `rootBoardId` matches no board, two boards share the same id, a board fails validation, two boards map the same media id to conflicting paths, a declared image/sound `path` has no matching resource, or a resource would overwrite a generated entry.
|
|
185
|
+
- `Invalid manifest:` — `manifest.json` failed to parse or validate, including a `root` that is not listed in `paths.boards`.
|
|
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
|
|
package/dist/index.d.mts
CHANGED
|
@@ -399,6 +399,7 @@ declare function parseManifest(json: string): OBFManifest;
|
|
|
399
399
|
* @returns A `Blob` containing the compressed OBZ archive.
|
|
400
400
|
*
|
|
401
401
|
* @throws {Error} If `rootBoardId` does not match any of the supplied boards.
|
|
402
|
+
* @throws {Error} If two supplied boards share the same id.
|
|
402
403
|
* @throws {Error} If a supplied board fails schema validation.
|
|
403
404
|
* @throws {Error} If two boards declare the same media id with conflicting paths.
|
|
404
405
|
* @throws {Error} If a board declares an image or sound `path` with no matching entry in `resources`.
|
|
@@ -430,22 +431,22 @@ type LoadedBoard = {
|
|
|
430
431
|
board: OBFBoard;
|
|
431
432
|
};
|
|
432
433
|
/**
|
|
433
|
-
* Detect whether
|
|
434
|
+
* Detect whether the input is a single OBF board or an OBZ package and load it
|
|
434
435
|
* accordingly.
|
|
435
436
|
*
|
|
436
|
-
* The
|
|
437
|
+
* The input is read once and its leading bytes are sniffed for the ZIP magic
|
|
437
438
|
* prefix: a ZIP is treated as an `.obz` package, anything else as an `.obf`
|
|
438
|
-
* board. This lets consumers accept either format from a single drag-and-drop
|
|
439
|
-
*
|
|
440
|
-
* OBF-vs-OBZ distinction themselves.
|
|
439
|
+
* board. This lets consumers accept either format from a single drag-and-drop,
|
|
440
|
+
* file picker, or fetch response without inspecting the file extension or
|
|
441
|
+
* re-deriving the OBF-vs-OBZ distinction themselves.
|
|
441
442
|
*
|
|
442
|
-
* @param
|
|
443
|
+
* @param input - A `File` handle or `ArrayBuffer` holding `.obf` or `.obz` content.
|
|
443
444
|
* @returns A discriminated union tagged by `format`.
|
|
444
445
|
*
|
|
445
446
|
* @throws {Error} If an OBZ archive is malformed or its manifest is missing,
|
|
446
447
|
* or if an OBF board is malformed or fails schema validation.
|
|
447
448
|
*/
|
|
448
|
-
declare function loadBoard(
|
|
449
|
+
declare function loadBoard(input: File | ArrayBuffer): Promise<LoadedBoard>;
|
|
449
450
|
//#endregion
|
|
450
451
|
//#region src/zip.d.ts
|
|
451
452
|
/**
|
package/dist/index.mjs
CHANGED
|
@@ -235,6 +235,9 @@ const OBFManifestSchema = z.looseObject({
|
|
|
235
235
|
/** Mapping of sound IDs to their file paths. */
|
|
236
236
|
sounds: z.record(z.string(), z.string()).optional()
|
|
237
237
|
})
|
|
238
|
+
}).refine((m) => Object.values(m.paths.boards).includes(m.root), {
|
|
239
|
+
message: "root must be listed in paths.boards",
|
|
240
|
+
path: ["root"]
|
|
238
241
|
});
|
|
239
242
|
//#endregion
|
|
240
243
|
//#region src/obf.ts
|
|
@@ -439,6 +442,7 @@ function parseManifest(json) {
|
|
|
439
442
|
* @returns A `Blob` containing the compressed OBZ archive.
|
|
440
443
|
*
|
|
441
444
|
* @throws {Error} If `rootBoardId` does not match any of the supplied boards.
|
|
445
|
+
* @throws {Error} If two supplied boards share the same id.
|
|
442
446
|
* @throws {Error} If a supplied board fails schema validation.
|
|
443
447
|
* @throws {Error} If two boards declare the same media id with conflicting paths.
|
|
444
448
|
* @throws {Error} If a board declares an image or sound `path` with no matching entry in `resources`.
|
|
@@ -446,6 +450,11 @@ function parseManifest(json) {
|
|
|
446
450
|
*/
|
|
447
451
|
async function createOBZ(boards, rootBoardId, resources) {
|
|
448
452
|
if (!boards.some((board) => board.id === rootBoardId)) throw new Error(`Invalid OBZ: rootBoardId "${rootBoardId}" does not match any supplied board`);
|
|
453
|
+
const seenBoardIds = /* @__PURE__ */ new Set();
|
|
454
|
+
for (const board of boards) {
|
|
455
|
+
if (seenBoardIds.has(board.id)) throw new Error(`Invalid OBZ: duplicate board id "${board.id}" — board ids must be unique within a package`);
|
|
456
|
+
seenBoardIds.add(board.id);
|
|
457
|
+
}
|
|
449
458
|
const entries = /* @__PURE__ */ new Map();
|
|
450
459
|
const boardPaths = Object.fromEntries(boards.map((board) => [board.id, `boards/${board.id}.obf`]));
|
|
451
460
|
const imagePaths = collectMediaPaths(boards, "images");
|
|
@@ -523,23 +532,23 @@ function extractBoards(manifest, entries) {
|
|
|
523
532
|
//#endregion
|
|
524
533
|
//#region src/load-board.ts
|
|
525
534
|
/**
|
|
526
|
-
* Detect whether
|
|
535
|
+
* Detect whether the input is a single OBF board or an OBZ package and load it
|
|
527
536
|
* accordingly.
|
|
528
537
|
*
|
|
529
|
-
* The
|
|
538
|
+
* The input is read once and its leading bytes are sniffed for the ZIP magic
|
|
530
539
|
* prefix: a ZIP is treated as an `.obz` package, anything else as an `.obf`
|
|
531
|
-
* board. This lets consumers accept either format from a single drag-and-drop
|
|
532
|
-
*
|
|
533
|
-
* OBF-vs-OBZ distinction themselves.
|
|
540
|
+
* board. This lets consumers accept either format from a single drag-and-drop,
|
|
541
|
+
* file picker, or fetch response without inspecting the file extension or
|
|
542
|
+
* re-deriving the OBF-vs-OBZ distinction themselves.
|
|
534
543
|
*
|
|
535
|
-
* @param
|
|
544
|
+
* @param input - A `File` handle or `ArrayBuffer` holding `.obf` or `.obz` content.
|
|
536
545
|
* @returns A discriminated union tagged by `format`.
|
|
537
546
|
*
|
|
538
547
|
* @throws {Error} If an OBZ archive is malformed or its manifest is missing,
|
|
539
548
|
* or if an OBF board is malformed or fails schema validation.
|
|
540
549
|
*/
|
|
541
|
-
async function loadBoard(
|
|
542
|
-
const buffer = await
|
|
550
|
+
async function loadBoard(input) {
|
|
551
|
+
const buffer = input instanceof ArrayBuffer ? input : await input.arrayBuffer();
|
|
543
552
|
if (isZip(buffer)) return {
|
|
544
553
|
format: "obz",
|
|
545
554
|
archive: await extractOBZ(buffer)
|
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.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 * @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 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 a `File` is a single OBF board or an OBZ package and load it\n * accordingly.\n *\n * The file 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 * or file picker without inspecting the file extension or re-deriving the\n * OBF-vs-OBZ distinction themselves.\n *\n * @param file - A `File` handle pointing to an `.obf` or `.obz` file.\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(file: File): Promise<LoadedBoard> {\n const buffer = await file.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":";;;;;;;;;;;;;;;AAeA,MAAM,uBAAuB,EAC1B,MAAM,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC,EAC9B,WAAW,QAAS,QAAQ,KAAK,KAAA,IAAY,GAAI,EACjD,SAAS;;AAGZ,MAAM,yBAAyB,EAC5B,MAAM,CAAC,EAAE,MAAM,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC,EAChC,WAAW,QAAS,QAAQ,KAAK,KAAA,IAAY,GAAI,EACjD,SAAS;;AAGZ,MAAM,sBAAsB,EACzB,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAC9B,WAAW,QAAQ;CAClB,MAAM,MAAM,OAAO,GAAG;CACtB,OAAO,QAAQ,KAAK,KAAA,IAAY;AAClC,CAAC,EACA,SAAS;;AAGZ,MAAa,cAAc,EACxB,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAC9B,WAAW,QAAQ,OAAO,GAAG,CAAC,EAC9B,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC;;;;AAMzB,MAAa,yBAAyB,EAAE,OAAO,EAAE,MAAM,iBAAiB;;;;;AAOxE,MAAa,sBAAsB,EAAE,OAAO;;;;AAM5C,MAAa,4BAA4B,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC;;;;;AAOxE,MAAa,mBAAmB,EAAE,OAAO,EAAE,OAAO,GAAG,yBAAyB;;;;;AAO9E,MAAa,0BAA0B,EAAE,OAAO,EAAE,MAAM,QAAQ;;;;;AAOhE,MAAa,2BAA2B,EACrC,OAAO,EACP,MAAM,sBAAsB;;;;AAM/B,MAAa,wBAAwB,EAAE,MAAM,CAC3C,yBACA,wBACF,CAAC;;;;AAMD,MAAa,mBAAmB,EAAE,YAAY;;CAE5C,MAAM,EAAE,OAAO;;CAEf,sBAAsB;;CAEtB,YAAY;;CAEZ,aAAa,EAAE,OAAO,EAAE,SAAS;;CAEjC,YAAY;;CAEZ,cAAc;AAChB,CAAC;;;;;;;;;AAYD,MAAa,iBAAiB,EAAE,YAAY;;CAE1C,IAAI;;CAEJ,MAAM,EAAE,OAAO,EAAE,SAAS;;CAE1B,MAAM,EAAE,OAAO,EAAE,SAAS;;CAE1B,UAAU;;CAEV,KAAK;;CAEL,cAAc,EAAE,OAAO,EAAE,SAAS;;CAElC,SAAS,iBAAiB,SAAS;AACrC,CAAC;;;;AAOD,MAAa,sBAAsB,EAAE,YAAY;;CAE/C,KAAK,EAAE,OAAO;;CAEd,UAAU,EAAE,OAAO;AACrB,CAAC;;;;;;;;;;;AAaD,MAAa,iBAAiB,eAAe,OAAO;;CAElD,QAAQ,oBAAoB,SAAS;;CAErC,OAAO,EAAE,OAAO,EAAE,SAAS;;CAE3B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAC9B,CAAC;;;;AAMD,MAAa,iBAAiB;;;;AAM9B,MAAa,qBAAqB,EAAE,YAAY;;CAE9C,IAAI;;CAEJ,MAAM,EAAE,OAAO,EAAE,SAAS;;CAE1B,UAAU;;CAEV,KAAK;;CAEL,MAAM,EAAE,OAAO,EAAE,SAAS;AAC5B,CAAC;;;;AAOD,MAAa,kBAAkB,EAAE,YAAY;;CAE3C,IAAI;;CAEJ,OAAO,EAAE,OAAO,EAAE,SAAS;;CAE3B,cAAc,EAAE,OAAO,EAAE,SAAS;;CAElC,UAAU;;CAEV,UAAU;;CAEV,QAAQ,sBAAsB,SAAS;;CAEvC,SAAS,EAAE,MAAM,qBAAqB,EAAE,SAAS;;CAEjD,YAAY,mBAAmB,SAAS;;CAExC,kBAAkB,EAAE,OAAO,EAAE,SAAS;;CAEtC,cAAc,EAAE,OAAO,EAAE,SAAS;;CAElC,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS;;CAEvC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS;;CAExC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS;;CAEzC,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS;AAC5C,CAAC;;;;AAOD,MAAa,gBAAgB,EAC1B,YAAY;;CAEX,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC;;CAE5B,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC;;;;;CAK/B,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;AAC1D,CAAC,EACA,QAAQ,MAAM,EAAE,MAAM,WAAW,EAAE,MAAM,EACxC,SAAS,oCACX,CAAC,EACA,QAAQ,MAAM,EAAE,MAAM,OAAO,QAAQ,IAAI,WAAW,EAAE,OAAO,GAAG,EAC/D,SAAS,kDACX,CAAC;;;;AAMH,MAAa,iBAAiB,EAAE,YAAY;;CAE1C,QAAQ;;CAER,IAAI;;CAEJ,QAAQ,oBAAoB,SAAS;;CAErC,SAAS,EAAE,MAAM,eAAe;;CAEhC,KAAK;;CAEL,MAAM,EAAE,OAAO,EAAE,SAAS;;CAE1B,kBAAkB,EAAE,OAAO,EAAE,SAAS;;CAEtC,MAAM;;CAEN,QAAQ,EAAE,MAAM,cAAc,EAAE,SAAS;;CAEzC,QAAQ,EAAE,MAAM,cAAc,EAAE,SAAS;;CAEzC,SAAS,iBAAiB,SAAS;;CAEnC,SAAS,iBAAiB,SAAS;AACrC,CAAC;;;;AAOD,MAAa,oBAAoB,EAAE,YAAY;;CAE7C,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,EAAE,SAAS;CACpD,CAAC;AACH,CAAC;;;AC5SD,MAAM,WAAW;;AAGjB,SAAS,SAAS,MAAsB;CACtC,OAAO,KAAK,WAAW,QAAQ,IAAI,KAAK,MAAM,CAAC,IAAI;AACrD;;AAGA,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;;;;;;;;;;AC/EA,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;;;;;;;;;;;;;;ACxDA,eAAsB,QAAQ,MAAgC;CAE5D,OAAO,WAAW,MADI,KAAK,YAAY,CACd;AAC3B;;;;;;;;;;AAWA,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;CAGxC,OAAO;EAAE;EAAU,QAFJ,cAAc,UAAU,OAEf;EAAG,WAAW;CAAQ;AAChD;;;;;;;;;;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;;;;;;;;;;;;;;;;;;AAmBA,eAAsB,UACpB,QACA,aACA,WACe;CACf,IAAI,CAAC,OAAO,MAAM,UAAU,MAAM,OAAO,WAAW,GAClD,MAAM,IAAI,MACR,6BAA6B,YAAY,oCAC3C;CAGF,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,EAAE,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,EAAE,OAAO,aACd,CAAC;AACnC;AAEA,SAAS,cACP,UACA,SACuB;CACvB,MAAM,yBAAS,IAAI,IAAsB;CAEzC,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;EAGF,MAAM,YAAY,IAAI,YAAY,EAAE,OAAO,UAAU;EACrD,OAAO,IAAI,IAAI,SAAS,SAAS,CAAC;CACpC;CAEA,OAAO;AACT;;;;;;;;;;;;;;;;;;;ACjOA,eAAsB,UAAU,MAAkC;CAChE,MAAM,SAAS,MAAM,KAAK,YAAY;CAEtC,IAAI,MAAM,MAAM,GACd,OAAO;EAAE,QAAQ;EAAO,SAAS,MAAM,WAAW,MAAM;CAAE;CAG5D,OAAO;EAAE,QAAQ;EAAO,OAAO,SAAS,IAAI,YAAY,EAAE,OAAO,MAAM,CAAC;CAAE;AAC5E"}
|
|
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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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shayc/open-board-format",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A TypeScript toolkit for Open Board Format — the open standard for Augmentative and Alternative Communication (AAC) boards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shay Cojocaru <shayc@outlook.com>",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"format": "prettier --write .",
|
|
42
42
|
"format:check": "prettier --check .",
|
|
43
43
|
"test": "vitest run",
|
|
44
|
+
"test:coverage": "vitest run --coverage",
|
|
44
45
|
"typecheck": "tsc",
|
|
45
46
|
"prepublishOnly": "npm run build",
|
|
46
47
|
"changeset": "changeset",
|
|
@@ -58,15 +59,16 @@
|
|
|
58
59
|
"@changesets/cli": "^2.31.0",
|
|
59
60
|
"@eslint/js": "^10.0.1",
|
|
60
61
|
"@types/node": "^22.0.0",
|
|
61
|
-
"
|
|
62
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
63
|
+
"eslint": "^10.4.1",
|
|
62
64
|
"eslint-config-prettier": "^10.1.8",
|
|
63
65
|
"globals": "^17.6.0",
|
|
64
|
-
"prettier": "^3.8.
|
|
66
|
+
"prettier": "^3.8.4",
|
|
65
67
|
"publint": "^0.3.21",
|
|
66
|
-
"tsdown": "^0.22.
|
|
68
|
+
"tsdown": "^0.22.2",
|
|
67
69
|
"typescript": "~6.0.3",
|
|
68
|
-
"typescript-eslint": "^8.
|
|
69
|
-
"vitest": "^4.1.
|
|
70
|
+
"typescript-eslint": "^8.61.0",
|
|
71
|
+
"vitest": "^4.1.8"
|
|
70
72
|
},
|
|
71
73
|
"publishConfig": {
|
|
72
74
|
"access": "public"
|