@shayc/open-board-format 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts +9 -8
- package/dist/index.mjs +17 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @shayc/open-board-format
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b2fa636: Expose a `types` condition in the package `exports` map so TypeScript consumers resolve declarations for the package entry point.
|
|
8
|
+
|
|
9
|
+
## 0.1.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 8f75789: - `createOBZ` now validates each board against `OBFBoardSchema` before writing it into the archive, so invalid input fails loudly instead of producing a structurally broken `.obf` (closes #13).
|
|
14
|
+
- `OBFImageSchema` is built with `.extend()` instead of `.and()`, so it is a regular object schema again (supports `.extend()`/`.pick()`/`.shape`); parsing behavior, including `ext_` passthrough, is unchanged.
|
|
15
|
+
- Corrected misleading JSDoc on `ParsedOBZ.resources` (it holds every archive entry, not only media) and on `OBFLocaleCodeSchema` (a plain string, not strictly BCP 47 validated; closes #14).
|
|
16
|
+
|
|
3
17
|
## 0.1.2
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/dist/index.d.mts
CHANGED
|
@@ -21,7 +21,8 @@ type OBFID = z.infer<typeof OBFIDSchema>;
|
|
|
21
21
|
declare const OBFFormatVersionSchema: z.ZodString;
|
|
22
22
|
type OBFFormatVersion = z.infer<typeof OBFFormatVersionSchema>;
|
|
23
23
|
/**
|
|
24
|
-
* Locale
|
|
24
|
+
* Locale identifier, typically a BCP 47 language tag (e.g., 'en', 'en-US', 'fr-CA').
|
|
25
|
+
* Not strictly validated — any string is accepted.
|
|
25
26
|
*/
|
|
26
27
|
declare const OBFLocaleCodeSchema: z.ZodString;
|
|
27
28
|
type OBFLocaleCode = z.infer<typeof OBFLocaleCodeSchema>;
|
|
@@ -108,7 +109,7 @@ type OBFSymbolInfo = z.infer<typeof OBFSymbolInfoSchema>;
|
|
|
108
109
|
* 3. `url`
|
|
109
110
|
* 4. `symbol`
|
|
110
111
|
*/
|
|
111
|
-
declare const OBFImageSchema: z.
|
|
112
|
+
declare const OBFImageSchema: z.ZodObject<{
|
|
112
113
|
id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
|
|
113
114
|
data: z.ZodOptional<z.ZodString>;
|
|
114
115
|
path: z.ZodOptional<z.ZodString>;
|
|
@@ -123,14 +124,13 @@ declare const OBFImageSchema: z.ZodIntersection<z.ZodObject<{
|
|
|
123
124
|
author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
124
125
|
author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
125
126
|
}, z.core.$loose>>;
|
|
126
|
-
}, z.core.$loose>, z.ZodObject<{
|
|
127
127
|
symbol: z.ZodOptional<z.ZodObject<{
|
|
128
128
|
set: z.ZodString;
|
|
129
129
|
filename: z.ZodString;
|
|
130
130
|
}, z.core.$loose>>;
|
|
131
131
|
width: z.ZodOptional<z.ZodNumber>;
|
|
132
132
|
height: z.ZodOptional<z.ZodNumber>;
|
|
133
|
-
}, z.core.$
|
|
133
|
+
}, z.core.$loose>;
|
|
134
134
|
type OBFImage = z.infer<typeof OBFImageSchema>;
|
|
135
135
|
/**
|
|
136
136
|
* Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.
|
|
@@ -235,7 +235,7 @@ declare const OBFBoardSchema: z.ZodObject<{
|
|
|
235
235
|
columns: z.ZodNumber;
|
|
236
236
|
order: z.ZodArray<z.ZodArray<z.ZodUnion<readonly [z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>, z.ZodNull]>>>;
|
|
237
237
|
}, z.core.$loose>;
|
|
238
|
-
images: z.ZodOptional<z.ZodArray<z.
|
|
238
|
+
images: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
239
239
|
id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
|
|
240
240
|
data: z.ZodOptional<z.ZodString>;
|
|
241
241
|
path: z.ZodOptional<z.ZodString>;
|
|
@@ -250,14 +250,13 @@ declare const OBFBoardSchema: z.ZodObject<{
|
|
|
250
250
|
author_url: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodURL, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
251
251
|
author_email: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>, z.ZodTransform<string | undefined, string>>>;
|
|
252
252
|
}, z.core.$loose>>;
|
|
253
|
-
}, z.core.$loose>, z.ZodObject<{
|
|
254
253
|
symbol: z.ZodOptional<z.ZodObject<{
|
|
255
254
|
set: z.ZodString;
|
|
256
255
|
filename: z.ZodString;
|
|
257
256
|
}, z.core.$loose>>;
|
|
258
257
|
width: z.ZodOptional<z.ZodNumber>;
|
|
259
258
|
height: z.ZodOptional<z.ZodNumber>;
|
|
260
|
-
}, z.core.$
|
|
259
|
+
}, z.core.$loose>>>;
|
|
261
260
|
sounds: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
262
261
|
id: z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<string, string | number>>, z.ZodString>;
|
|
263
262
|
data: z.ZodOptional<z.ZodString>;
|
|
@@ -347,7 +346,9 @@ declare function stringifyOBF(board: OBFBoard): string;
|
|
|
347
346
|
*
|
|
348
347
|
* @property manifest - The package table of contents.
|
|
349
348
|
* @property boards - Board ID → validated board object.
|
|
350
|
-
* @property resources - Archive path → raw
|
|
349
|
+
* @property resources - Archive path → raw bytes for every entry in the archive,
|
|
350
|
+
* including `manifest.json` and the `.obf` boards as well as
|
|
351
|
+
* media such as images and sounds.
|
|
351
352
|
*/
|
|
352
353
|
interface ParsedOBZ {
|
|
353
354
|
manifest: OBFManifest;
|
package/dist/index.mjs
CHANGED
|
@@ -28,7 +28,8 @@ const OBFIDSchema = z.union([z.string(), z.number()]).transform((val) => String(
|
|
|
28
28
|
*/
|
|
29
29
|
const OBFFormatVersionSchema = z.string().regex(/^open-board-.+$/);
|
|
30
30
|
/**
|
|
31
|
-
* Locale
|
|
31
|
+
* Locale identifier, typically a BCP 47 language tag (e.g., 'en', 'en-US', 'fr-CA').
|
|
32
|
+
* Not strictly validated — any string is accepted.
|
|
32
33
|
*/
|
|
33
34
|
const OBFLocaleCodeSchema = z.string();
|
|
34
35
|
/**
|
|
@@ -114,14 +115,14 @@ const OBFSymbolInfoSchema = z.looseObject({
|
|
|
114
115
|
* 3. `url`
|
|
115
116
|
* 4. `symbol`
|
|
116
117
|
*/
|
|
117
|
-
const OBFImageSchema = OBFMediaSchema.
|
|
118
|
+
const OBFImageSchema = OBFMediaSchema.extend({
|
|
118
119
|
/** Information about a symbol from a proprietary symbol set. */
|
|
119
120
|
symbol: OBFSymbolInfoSchema.optional(),
|
|
120
121
|
/** Width of the image in pixels. */
|
|
121
122
|
width: z.number().optional(),
|
|
122
123
|
/** Height of the image in pixels. */
|
|
123
124
|
height: z.number().optional()
|
|
124
|
-
})
|
|
125
|
+
});
|
|
125
126
|
/**
|
|
126
127
|
* Audio resource. Identical to {@link OBFMediaSchema} — no additional properties.
|
|
127
128
|
*/
|
|
@@ -242,10 +243,10 @@ const UTF8_BOM = "";
|
|
|
242
243
|
function stripBom(text) {
|
|
243
244
|
return text.startsWith(UTF8_BOM) ? text.slice(1) : text;
|
|
244
245
|
}
|
|
245
|
-
/** Build a descriptive parse-failure message, preserving the engine's reason when available. */
|
|
246
|
-
function
|
|
246
|
+
/** Build a descriptive JSON parse-failure message, preserving the engine's reason when available. */
|
|
247
|
+
function buildJsonParseErrorMessage(label, error) {
|
|
247
248
|
const reason = error instanceof Error ? error.message : "";
|
|
248
|
-
return reason ? `Invalid
|
|
249
|
+
return reason ? `Invalid ${label}: JSON parse failed — ${reason}` : `Invalid ${label}: JSON parse failed`;
|
|
249
250
|
}
|
|
250
251
|
/**
|
|
251
252
|
* Parse a JSON string into a validated OBF board.
|
|
@@ -264,7 +265,7 @@ function parseOBF(json) {
|
|
|
264
265
|
try {
|
|
265
266
|
rawBoard = JSON.parse(sanitized);
|
|
266
267
|
} catch (error) {
|
|
267
|
-
throw new Error(
|
|
268
|
+
throw new Error(buildJsonParseErrorMessage("OBF", error), { cause: error });
|
|
268
269
|
}
|
|
269
270
|
return validateOBF(rawBoard);
|
|
270
271
|
}
|
|
@@ -420,7 +421,7 @@ function parseManifest(json) {
|
|
|
420
421
|
try {
|
|
421
422
|
data = JSON.parse(json);
|
|
422
423
|
} catch (error) {
|
|
423
|
-
throw new Error(
|
|
424
|
+
throw new Error(buildJsonParseErrorMessage("manifest", error), { cause: error });
|
|
424
425
|
}
|
|
425
426
|
const result = OBFManifestSchema.safeParse(data);
|
|
426
427
|
if (!result.success) throw new Error(`Invalid manifest: ${result.error.message}`);
|
|
@@ -445,7 +446,7 @@ async function createOBZ(boards, rootBoardId, resources) {
|
|
|
445
446
|
const boardPaths = Object.fromEntries(boards.map((board) => [board.id, `boards/${board.id}.obf`]));
|
|
446
447
|
const imagePaths = collectMediaPaths(boards, "images");
|
|
447
448
|
const soundPaths = collectMediaPaths(boards, "sounds");
|
|
448
|
-
const
|
|
449
|
+
const manifestResult = OBFManifestSchema.safeParse({
|
|
449
450
|
format: "open-board-0.1",
|
|
450
451
|
root: `boards/${rootBoardId}.obf`,
|
|
451
452
|
paths: {
|
|
@@ -454,15 +455,19 @@ async function createOBZ(boards, rootBoardId, resources) {
|
|
|
454
455
|
...Object.keys(soundPaths).length > 0 ? { sounds: soundPaths } : {}
|
|
455
456
|
}
|
|
456
457
|
});
|
|
458
|
+
if (!manifestResult.success) throw new Error(`Invalid OBZ: generated manifest failed validation — ${manifestResult.error.message}`);
|
|
459
|
+
const manifest = manifestResult.data;
|
|
457
460
|
const encoder = new TextEncoder();
|
|
458
461
|
entries.set("manifest.json", encoder.encode(JSON.stringify(manifest, null, 2)));
|
|
459
462
|
for (const board of boards) {
|
|
460
|
-
const
|
|
461
|
-
|
|
463
|
+
const result = OBFBoardSchema.safeParse(board);
|
|
464
|
+
if (!result.success) throw new Error(`Invalid OBZ: board "${board.id}" failed validation — ${result.error.message}`);
|
|
465
|
+
const path = `boards/${result.data.id}.obf`;
|
|
466
|
+
entries.set(path, encoder.encode(JSON.stringify(result.data, null, 2)));
|
|
462
467
|
}
|
|
463
468
|
if (resources) for (const [path, bytes] of resources) entries.set(path, bytes);
|
|
464
469
|
const compressed = await zip(entries);
|
|
465
|
-
return new Blob([
|
|
470
|
+
return new Blob([compressed], { type: "application/zip" });
|
|
466
471
|
}
|
|
467
472
|
/**
|
|
468
473
|
* Walk every board's media collection and produce the `{ id -> path }` map
|
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"],"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 code as per BCP 47 language tags, e.g., 'en', 'en-US', 'fr-CA'.\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.and(\n z.object({\n /** Information about a symbol from a proprietary symbol set. */\n symbol: OBFSymbolInfoSchema.optional(),\n /** Width of the image in pixels. */\n width: z.number().optional(),\n /** Height of the image in pixels. */\n height: z.number().optional(),\n }),\n);\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 parse-failure message, preserving the engine's reason when available. */\nfunction buildParseErrorMessage(error: unknown): string {\n const reason = error instanceof Error ? error.message : \"\";\n return reason\n ? `Invalid OBF: JSON parse failed — ${reason}`\n : \"Invalid OBF: 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(buildParseErrorMessage(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 { parseOBF } from \"./obf\";\nimport type { OBFBoard, OBFManifest } from \"./schema\";\nimport { 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 binary content (images, sounds, etc.).\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(\n `Invalid manifest: JSON parse failed${\n (error as Error)?.message ? ` — ${(error as Error).message}` : \"\"\n }`,\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 manifest = OBFManifestSchema.parse({\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 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 path = `boards/${board.id}.obf`;\n entries.set(path, encoder.encode(JSON.stringify(board, 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([new Uint8Array(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 `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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shayc/open-board-format",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Parse, validate, and create Open Board Format (OBF/OBZ) files for AAC applications.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shay Cojocaru <shayc@outlook.com>",
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
"type": "module",
|
|
21
21
|
"sideEffects": false,
|
|
22
22
|
"exports": {
|
|
23
|
-
".":
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.mts",
|
|
25
|
+
"default": "./dist/index.mjs"
|
|
26
|
+
},
|
|
24
27
|
"./package.json": "./package.json"
|
|
25
28
|
},
|
|
26
29
|
"files": [
|