@quillmark/quiver 0.1.0 → 0.2.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.
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Throws a `transport_error` when called outside a Node.js environment.
3
+ * Call at the top of each Node-only static factory to fail fast in browsers.
4
+ */
5
+ export declare function assertNode(method: string): void;
@@ -0,0 +1,12 @@
1
+ import { QuiverError } from "./errors.js";
2
+ /**
3
+ * Throws a `transport_error` when called outside a Node.js environment.
4
+ * Call at the top of each Node-only static factory to fail fast in browsers.
5
+ */
6
+ export function assertNode(method) {
7
+ if (typeof globalThis.process === "undefined" ||
8
+ !globalThis.process
9
+ ?.versions?.node) {
10
+ throw new QuiverError("transport_error", `${method} is only available in Node.js`);
11
+ }
12
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Zip utilities — browser-safe (uses fflate only).
3
+ */
4
+ /**
5
+ * Pack a flat file map into a deterministic zip.
6
+ * Keys are sorted before zipping so insertion order doesn't affect output.
7
+ */
8
+ export declare function packFiles(files: Record<string, Uint8Array>): Uint8Array;
9
+ /**
10
+ * Unpack a zip into a flat file map.
11
+ * Returns { path: Uint8Array } for every file entry in the archive.
12
+ */
13
+ export declare function unpackFiles(data: Uint8Array): Record<string, Uint8Array>;
package/dist/bundle.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Zip utilities — browser-safe (uses fflate only).
3
+ */
4
+ import { zipSync, unzipSync } from "fflate";
5
+ /**
6
+ * Fixed epoch mtime for deterministic zip output.
7
+ * All entries get this timestamp so byte-identical inputs → byte-identical zips.
8
+ */
9
+ const ZIP_EPOCH = new Date(Date.UTC(1980, 0, 1));
10
+ /**
11
+ * Pack a flat file map into a deterministic zip.
12
+ * Keys are sorted before zipping so insertion order doesn't affect output.
13
+ */
14
+ export function packFiles(files) {
15
+ const sorted = Object.keys(files).sort();
16
+ const input = {};
17
+ for (const key of sorted) {
18
+ input[key] = [files[key], { mtime: ZIP_EPOCH }];
19
+ }
20
+ return zipSync(input, { level: 6 });
21
+ }
22
+ /**
23
+ * Unpack a zip into a flat file map.
24
+ * Returns { path: Uint8Array } for every file entry in the archive.
25
+ */
26
+ export function unpackFiles(data) {
27
+ const raw = unzipSync(data);
28
+ const result = {};
29
+ for (const [key, value] of Object.entries(raw)) {
30
+ result[key] = value;
31
+ }
32
+ return result;
33
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Minimal structural types matching @quillmark/wasm >=0.57.0 (verified against 0.59.0-rc.2).
3
+ *
4
+ * Shape:
5
+ * class Quillmark { quill(tree: Map<string, Uint8Array>): Quill }
6
+ * class Quill { render(doc, opts?): RenderResult; open(doc): RenderSession }
7
+ *
8
+ * Note: as of 0.59.0-rc.2 the first arg to render/open is a `Document` instance
9
+ * (from `Document.fromMarkdown(...)`), not the old `ParsedDocument` interface.
10
+ * Quiver keeps the arg typed as `unknown` so consumers of either shape (and
11
+ * test doubles) satisfy the contract structurally.
12
+ *
13
+ * These types are INTERNAL — never re-exported from index.ts. They exist so
14
+ * registry.ts never imports from @quillmark/wasm directly and so test doubles
15
+ * can satisfy the contract without pulling the real WASM module.
16
+ *
17
+ * Call-site note: Quiver never invokes `render` or `open` itself; consumers do
18
+ * after `getQuill()`. The loose `unknown` parameter typing is intentional.
19
+ */
20
+ export interface QuillmarkLike {
21
+ quill(tree: Map<string, Uint8Array>): QuillLike;
22
+ }
23
+ export interface QuillLike {
24
+ render(doc: unknown, opts?: unknown): unknown;
25
+ open?: (doc: unknown) => unknown;
26
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Minimal structural types matching @quillmark/wasm >=0.57.0 (verified against 0.59.0-rc.2).
3
+ *
4
+ * Shape:
5
+ * class Quillmark { quill(tree: Map<string, Uint8Array>): Quill }
6
+ * class Quill { render(doc, opts?): RenderResult; open(doc): RenderSession }
7
+ *
8
+ * Note: as of 0.59.0-rc.2 the first arg to render/open is a `Document` instance
9
+ * (from `Document.fromMarkdown(...)`), not the old `ParsedDocument` interface.
10
+ * Quiver keeps the arg typed as `unknown` so consumers of either shape (and
11
+ * test doubles) satisfy the contract structurally.
12
+ *
13
+ * These types are INTERNAL — never re-exported from index.ts. They exist so
14
+ * registry.ts never imports from @quillmark/wasm directly and so test doubles
15
+ * can satisfy the contract without pulling the real WASM module.
16
+ *
17
+ * Call-site note: Quiver never invokes `render` or `open` itself; consumers do
18
+ * after `getQuill()`. The loose `unknown` parameter typing is intentional.
19
+ */
20
+ export {};
@@ -0,0 +1,16 @@
1
+ export type QuiverErrorCode = "invalid_ref" | "quill_not_found" | "quiver_invalid" | "transport_error" | "quiver_collision";
2
+ export declare class QuiverError extends Error {
3
+ readonly code: QuiverErrorCode;
4
+ /** Offending ref string, when available. */
5
+ readonly ref?: string;
6
+ /** Offending version, when available. */
7
+ readonly version?: string;
8
+ /** Quiver `name` from Quiver.yaml, when available. */
9
+ readonly quiverName?: string;
10
+ constructor(code: QuiverErrorCode, message: string, options?: {
11
+ ref?: string;
12
+ version?: string;
13
+ quiverName?: string;
14
+ cause?: unknown;
15
+ });
16
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,17 @@
1
+ export class QuiverError extends Error {
2
+ code;
3
+ /** Offending ref string, when available. */
4
+ ref;
5
+ /** Offending version, when available. */
6
+ version;
7
+ /** Quiver `name` from Quiver.yaml, when available. */
8
+ quiverName;
9
+ constructor(code, message, options) {
10
+ super(message, { cause: options?.cause });
11
+ this.name = "QuiverError";
12
+ this.code = code;
13
+ this.ref = options?.ref;
14
+ this.version = options?.version;
15
+ this.quiverName = options?.quiverName;
16
+ }
17
+ }
@@ -0,0 +1,5 @@
1
+ export { QuiverError } from "./errors.js";
2
+ export type { QuiverErrorCode } from "./errors.js";
3
+ export { Quiver } from "./quiver.js";
4
+ export { QuiverRegistry } from "./registry.js";
5
+ export type { PackOptions } from "./pack.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // Main browser-safe entrypoint.
2
+ export { QuiverError } from "./errors.js";
3
+ export { Quiver } from "./quiver.js";
4
+ export { QuiverRegistry } from "./registry.js";
package/dist/node.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./index.js";
package/dist/node.js ADDED
@@ -0,0 +1,5 @@
1
+ // Node-only entrypoint.
2
+ // Re-export everything from the main browser-safe entrypoint.
3
+ // Node-only factories (fromSourceDir, fromPackedDir, pack) are methods on
4
+ // Quiver itself — no additional exports are needed here.
5
+ export * from "./index.js";
package/dist/pack.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Pack logic — internal, Node-only.
3
+ *
4
+ * All Node.js built-in imports are done dynamically inside `packQuiver` so
5
+ * that a type-only import of `PackOptions` from `src/index.ts` does NOT
6
+ * pull `node:fs` or `node:crypto` into browser bundles.
7
+ */
8
+ /** Reserved for future pack options (e.g. compression level, filters). */
9
+ export type PackOptions = Record<string, never>;
10
+ /**
11
+ * Reads a Source Quiver, validates it, and writes a Packed Quiver to outDir.
12
+ *
13
+ * Output layout:
14
+ * outDir/
15
+ * Quiver.json # stable pointer
16
+ * manifest.<md5prefix6>.json # hashed manifest
17
+ * <name>@<version>.<md5>.zip # one bundle per quill
18
+ * store/
19
+ * <md5> # dehydrated font bytes (full hash, no ext)
20
+ *
21
+ * Throws:
22
+ * - `quiver_invalid` on source validation failures (propagated from scanner)
23
+ * - `transport_error` on I/O failures
24
+ */
25
+ export declare function packQuiver(sourceDir: string, outDir: string, _opts?: PackOptions): Promise<void>;
package/dist/pack.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Pack logic — internal, Node-only.
3
+ *
4
+ * All Node.js built-in imports are done dynamically inside `packQuiver` so
5
+ * that a type-only import of `PackOptions` from `src/index.ts` does NOT
6
+ * pull `node:fs` or `node:crypto` into browser bundles.
7
+ */
8
+ import { QuiverError } from "./errors.js";
9
+ import { packFiles } from "./bundle.js";
10
+ /** Font file extensions recognised by the packer (case-insensitive). */
11
+ const FONT_EXT = /\.(ttf|otf|woff|woff2)$/i;
12
+ /**
13
+ * Reads a Source Quiver, validates it, and writes a Packed Quiver to outDir.
14
+ *
15
+ * Output layout:
16
+ * outDir/
17
+ * Quiver.json # stable pointer
18
+ * manifest.<md5prefix6>.json # hashed manifest
19
+ * <name>@<version>.<md5>.zip # one bundle per quill
20
+ * store/
21
+ * <md5> # dehydrated font bytes (full hash, no ext)
22
+ *
23
+ * Throws:
24
+ * - `quiver_invalid` on source validation failures (propagated from scanner)
25
+ * - `transport_error` on I/O failures
26
+ */
27
+ export async function packQuiver(sourceDir, outDir, _opts) {
28
+ // Dynamic imports keep this module safe to type-import from browser contexts.
29
+ const { join } = await import("node:path");
30
+ const { mkdir, rm, writeFile, } = await import("node:fs/promises");
31
+ const { createHash } = await import("node:crypto");
32
+ const { scanSourceQuiver, readQuillTree } = await import("./source-loader.js");
33
+ // 1. Scan + validate source quiver (throws quiver_invalid on bad input).
34
+ const { meta, catalog } = await scanSourceQuiver(sourceDir);
35
+ // 2. Clear and recreate outDir + outDir/store/.
36
+ try {
37
+ await rm(outDir, { recursive: true, force: true });
38
+ await mkdir(join(outDir, "store"), { recursive: true });
39
+ }
40
+ catch (err) {
41
+ throw new QuiverError("transport_error", `Failed to prepare output directory "${outDir}": ${err.message}`, { cause: err });
42
+ }
43
+ // 3. Process each quill version.
44
+ const manifestQuills = [];
45
+ for (const [quillName, versions] of catalog) {
46
+ for (const version of versions) {
47
+ const quillDir = join(sourceDir, "quills", quillName, version);
48
+ // a. Read quill file tree.
49
+ const tree = await readQuillTree(quillDir);
50
+ // b. Partition fonts vs content.
51
+ const fontEntries = [];
52
+ const contentEntries = [];
53
+ for (const [path, bytes] of tree) {
54
+ if (FONT_EXT.test(path)) {
55
+ fontEntries.push([path, bytes]);
56
+ }
57
+ else {
58
+ contentEntries.push([path, bytes]);
59
+ }
60
+ }
61
+ // c. Dehydrate fonts into store/.
62
+ const fonts = {};
63
+ for (const [path, bytes] of fontEntries) {
64
+ const hash = createHash("md5").update(bytes).digest("hex");
65
+ const storePath = join(outDir, "store", hash);
66
+ try {
67
+ await writeFile(storePath, bytes);
68
+ }
69
+ catch (err) {
70
+ throw new QuiverError("transport_error", `Failed to write font store entry "${storePath}": ${err.message}`, { cause: err });
71
+ }
72
+ fonts[path] = hash;
73
+ }
74
+ // d. Zip content files (deterministic: sorted paths, fixed mtime).
75
+ const contentRecord = {};
76
+ for (const [path, bytes] of contentEntries) {
77
+ contentRecord[path] = bytes;
78
+ }
79
+ const zipBytes = packFiles(contentRecord);
80
+ // e–f. Compute bundle hash and name.
81
+ const bundleHash = createHash("md5").update(zipBytes).digest("hex").slice(0, 6);
82
+ const bundleName = `${quillName}@${version}.${bundleHash}.zip`;
83
+ // g. Write bundle zip.
84
+ const bundlePath = join(outDir, bundleName);
85
+ try {
86
+ await writeFile(bundlePath, zipBytes);
87
+ }
88
+ catch (err) {
89
+ throw new QuiverError("transport_error", `Failed to write bundle "${bundlePath}": ${err.message}`, { cause: err });
90
+ }
91
+ // h. Record manifest entry.
92
+ manifestQuills.push({ name: quillName, version, bundle: bundleName, fonts });
93
+ }
94
+ }
95
+ // 4–8. Build and write hashed manifest.
96
+ const manifest = {
97
+ version: 1,
98
+ name: meta.name,
99
+ quills: manifestQuills,
100
+ };
101
+ const manifestJson = JSON.stringify(manifest, null, 2);
102
+ const manifestHash = createHash("md5").update(manifestJson).digest("hex").slice(0, 6);
103
+ const manifestFileName = `manifest.${manifestHash}.json`;
104
+ const manifestPath = join(outDir, manifestFileName);
105
+ try {
106
+ await writeFile(manifestPath, manifestJson, "utf-8");
107
+ }
108
+ catch (err) {
109
+ throw new QuiverError("transport_error", `Failed to write manifest "${manifestPath}": ${err.message}`, { cause: err });
110
+ }
111
+ // 9–10. Write stable pointer Quiver.json.
112
+ const pointer = { manifest: manifestFileName };
113
+ const pointerPath = join(outDir, "Quiver.json");
114
+ try {
115
+ await writeFile(pointerPath, JSON.stringify(pointer), "utf-8");
116
+ }
117
+ catch (err) {
118
+ throw new QuiverError("transport_error", `Failed to write pointer "${pointerPath}": ${err.message}`, { cause: err });
119
+ }
120
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Packed Quiver loader — browser-safe at module level.
3
+ * Internal; not exported from index.ts.
4
+ *
5
+ * Exposes:
6
+ * - PackedTransport interface (also used by FsTransport / HttpTransport)
7
+ * - loadPackedQuiver(transport) → Quiver
8
+ *
9
+ * NO static node: imports — this module is safe to load in browser contexts.
10
+ */
11
+ import { Quiver } from "./quiver.js";
12
+ /**
13
+ * Transport abstraction: fetch raw bytes by relative path within the packed
14
+ * artifact. Implementations are FsTransport (Node) and HttpTransport (browser).
15
+ */
16
+ export interface PackedTransport {
17
+ fetchBytes(relativePath: string): Promise<Uint8Array>;
18
+ }
19
+ /**
20
+ * Load a Packed Quiver via the given transport.
21
+ *
22
+ * 1. Fetches Quiver.json (pointer) and parses it.
23
+ * 2. Fetches the manifest file it points to and validates it.
24
+ * 3. Builds a catalog from manifest entries (versions sorted descending).
25
+ * 4. Returns a Quiver instance backed by a PackedLoader.
26
+ */
27
+ export declare function loadPackedQuiver(transport: PackedTransport): Promise<Quiver>;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Packed Quiver loader — browser-safe at module level.
3
+ * Internal; not exported from index.ts.
4
+ *
5
+ * Exposes:
6
+ * - PackedTransport interface (also used by FsTransport / HttpTransport)
7
+ * - loadPackedQuiver(transport) → Quiver
8
+ *
9
+ * NO static node: imports — this module is safe to load in browser contexts.
10
+ */
11
+ import { QuiverError } from "./errors.js";
12
+ import { unpackFiles } from "./bundle.js";
13
+ import { isCanonicalSemver, compareSemver } from "./semver.js";
14
+ import { Quiver } from "./quiver.js";
15
+ // ─── Path validation ──────────────────────────────────────────────────────────
16
+ const MANIFEST_FILENAME_RE = /^manifest\.[0-9a-f]+\.json$/;
17
+ const BUNDLE_FILENAME_RE = /^[A-Za-z0-9_.-]+@[0-9]+\.[0-9]+\.[0-9]+\.[0-9a-f]+\.zip$/;
18
+ const FONT_HASH_RE = /^[0-9a-f]{32}$/;
19
+ function validateManifestFileName(name) {
20
+ if (!MANIFEST_FILENAME_RE.test(name)) {
21
+ throw new QuiverError("quiver_invalid", `Pointer manifest filename is invalid: "${name}"`);
22
+ }
23
+ }
24
+ function validateBundleFileName(bundle, context) {
25
+ if (!BUNDLE_FILENAME_RE.test(bundle)) {
26
+ throw new QuiverError("quiver_invalid", `${context}: bundle filename is invalid: "${bundle}"`);
27
+ }
28
+ }
29
+ function validateFontHash(hash, context) {
30
+ if (!FONT_HASH_RE.test(hash)) {
31
+ throw new QuiverError("quiver_invalid", `${context}: font hash is invalid: "${hash}"`);
32
+ }
33
+ }
34
+ // ─── PackedLoader implementation ─────────────────────────────────────────────
35
+ class PackedLoader {
36
+ transport;
37
+ index;
38
+ /** Font byte cache: hash → in-flight or resolved Promise. */
39
+ fontCache = new Map();
40
+ constructor(transport,
41
+ /** Index map from "name@version" to its manifest entry. */
42
+ index) {
43
+ this.transport = transport;
44
+ this.index = index;
45
+ }
46
+ async loadTree(name, version) {
47
+ const entry = this.index.get(`${name}@${version}`);
48
+ // If entry is missing, the outer Quiver.loadTree gate already validated
49
+ // that this name/version is in the catalog; trust that gate and fall
50
+ // through. The transport will surface a transport_error naturally.
51
+ // (Defensive: this should not be reachable in normal operation.)
52
+ if (!entry) {
53
+ throw new QuiverError("transport_error", `Quill "${name}@${version}" not found in packed quiver manifest`, { version, ref: `${name}@${version}` });
54
+ }
55
+ // 1. Fetch + unpack bundle zip.
56
+ const zipBytes = await this.transport.fetchBytes(entry.bundle);
57
+ const files = unpackFiles(zipBytes);
58
+ // 2. Rehydrate fonts from store (coalesced).
59
+ const fontEntries = Object.entries(entry.fonts);
60
+ await Promise.all(fontEntries.map(async ([path, hash]) => {
61
+ files[path] = await this.fetchFont(hash);
62
+ }));
63
+ // 3. Convert to Map.
64
+ return new Map(Object.entries(files));
65
+ }
66
+ /**
67
+ * Fetch a font by hash from store/<hash>, coalescing concurrent requests for
68
+ * the same hash into a single fetch. On error, removes the cache entry so
69
+ * callers can retry.
70
+ */
71
+ fetchFont(hash) {
72
+ let promise = this.fontCache.get(hash);
73
+ if (!promise) {
74
+ promise = this.transport
75
+ .fetchBytes(`store/${hash}`)
76
+ .catch((err) => {
77
+ this.fontCache.delete(hash);
78
+ throw err;
79
+ });
80
+ this.fontCache.set(hash, promise);
81
+ }
82
+ return promise;
83
+ }
84
+ }
85
+ // ─── Pointer + manifest validation helpers ────────────────────────────────────
86
+ function assertNoUnknownKeys(obj, allowed, context) {
87
+ for (const key of Object.keys(obj)) {
88
+ if (!allowed.includes(key)) {
89
+ throw new QuiverError("quiver_invalid", `${context}: unknown field "${key}"`);
90
+ }
91
+ }
92
+ }
93
+ function parsePointer(raw) {
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(raw);
97
+ }
98
+ catch {
99
+ throw new QuiverError("quiver_invalid", "Quiver.json contains invalid JSON");
100
+ }
101
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
102
+ throw new QuiverError("quiver_invalid", "Quiver.json must be a JSON object");
103
+ }
104
+ const obj = parsed;
105
+ assertNoUnknownKeys(obj, ["manifest"], "Quiver.json");
106
+ if (typeof obj["manifest"] !== "string" || obj["manifest"].length === 0) {
107
+ throw new QuiverError("quiver_invalid", 'Quiver.json must have a non-empty string "manifest" field');
108
+ }
109
+ return obj["manifest"];
110
+ }
111
+ function parseManifest(raw) {
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse(raw);
115
+ }
116
+ catch {
117
+ throw new QuiverError("quiver_invalid", "Manifest file contains invalid JSON");
118
+ }
119
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
120
+ throw new QuiverError("quiver_invalid", "Manifest must be a JSON object");
121
+ }
122
+ const obj = parsed;
123
+ assertNoUnknownKeys(obj, ["version", "name", "quills"], "manifest");
124
+ if (obj["version"] !== 1) {
125
+ throw new QuiverError("quiver_invalid", `Manifest version must be 1, got ${String(obj["version"])}`);
126
+ }
127
+ if (typeof obj["name"] !== "string" || obj["name"].length === 0) {
128
+ throw new QuiverError("quiver_invalid", 'Manifest must have a non-empty string "name" field');
129
+ }
130
+ if (!Array.isArray(obj["quills"])) {
131
+ throw new QuiverError("quiver_invalid", 'Manifest must have a "quills" array');
132
+ }
133
+ const quills = [];
134
+ for (let i = 0; i < obj["quills"].length; i++) {
135
+ const entry = obj["quills"][i];
136
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
137
+ throw new QuiverError("quiver_invalid", `manifest.quills[${i}] must be an object`);
138
+ }
139
+ const e = entry;
140
+ assertNoUnknownKeys(e, ["name", "version", "bundle", "fonts"], `manifest.quills[${i}]`);
141
+ if (typeof e["name"] !== "string" || e["name"].length === 0) {
142
+ throw new QuiverError("quiver_invalid", `manifest.quills[${i}].name must be a non-empty string`);
143
+ }
144
+ if (typeof e["version"] !== "string" ||
145
+ !isCanonicalSemver(e["version"])) {
146
+ throw new QuiverError("quiver_invalid", `manifest.quills[${i}].version must be canonical semver (x.y.z), got "${String(e["version"])}"`);
147
+ }
148
+ if (typeof e["bundle"] !== "string" ||
149
+ e["bundle"].length === 0) {
150
+ throw new QuiverError("quiver_invalid", `manifest.quills[${i}].bundle must be a non-empty string`);
151
+ }
152
+ validateBundleFileName(e["bundle"], `manifest.quills[${i}].bundle`);
153
+ if (typeof e["fonts"] !== "object" ||
154
+ e["fonts"] === null ||
155
+ Array.isArray(e["fonts"])) {
156
+ throw new QuiverError("quiver_invalid", `manifest.quills[${i}].fonts must be an object`);
157
+ }
158
+ const fonts = e["fonts"];
159
+ for (const [k, v] of Object.entries(fonts)) {
160
+ if (typeof v !== "string") {
161
+ throw new QuiverError("quiver_invalid", `manifest.quills[${i}].fonts["${k}"] must be a string`);
162
+ }
163
+ validateFontHash(v, `manifest.quills[${i}].fonts["${k}"]`);
164
+ }
165
+ quills.push({
166
+ name: e["name"],
167
+ version: e["version"],
168
+ bundle: e["bundle"],
169
+ fonts: fonts,
170
+ });
171
+ }
172
+ return {
173
+ version: 1,
174
+ name: obj["name"],
175
+ quills,
176
+ };
177
+ }
178
+ // ─── Main entry point ─────────────────────────────────────────────────────────
179
+ /**
180
+ * Load a Packed Quiver via the given transport.
181
+ *
182
+ * 1. Fetches Quiver.json (pointer) and parses it.
183
+ * 2. Fetches the manifest file it points to and validates it.
184
+ * 3. Builds a catalog from manifest entries (versions sorted descending).
185
+ * 4. Returns a Quiver instance backed by a PackedLoader.
186
+ */
187
+ export async function loadPackedQuiver(transport) {
188
+ // 1. Fetch and parse pointer.
189
+ let pointerBytes;
190
+ try {
191
+ pointerBytes = await transport.fetchBytes("Quiver.json");
192
+ }
193
+ catch (err) {
194
+ if (err instanceof QuiverError)
195
+ throw err;
196
+ throw new QuiverError("transport_error", `Failed to fetch Quiver.json: ${err.message}`, { cause: err });
197
+ }
198
+ const manifestFileName = parsePointer(new TextDecoder().decode(pointerBytes));
199
+ validateManifestFileName(manifestFileName);
200
+ // 2. Fetch and parse manifest.
201
+ let manifestBytes;
202
+ try {
203
+ manifestBytes = await transport.fetchBytes(manifestFileName);
204
+ }
205
+ catch (err) {
206
+ if (err instanceof QuiverError)
207
+ throw err;
208
+ throw new QuiverError("transport_error", `Failed to fetch manifest "${manifestFileName}": ${err.message}`, { cause: err });
209
+ }
210
+ const manifest = parseManifest(new TextDecoder().decode(manifestBytes));
211
+ // 3. Build catalog: name → versions sorted descending.
212
+ // Also build index map: "name@version" → entry (with duplicate detection).
213
+ const catalogRaw = new Map();
214
+ const index = new Map();
215
+ for (const entry of manifest.quills) {
216
+ const key = `${entry.name}@${entry.version}`;
217
+ if (index.has(key)) {
218
+ throw new QuiverError("quiver_invalid", `Duplicate quill entry in manifest: "${key}"`);
219
+ }
220
+ index.set(key, entry);
221
+ const versions = catalogRaw.get(entry.name) ?? [];
222
+ versions.push(entry.version);
223
+ catalogRaw.set(entry.name, versions);
224
+ }
225
+ for (const [, versions] of catalogRaw) {
226
+ versions.sort((a, b) => compareSemver(b, a));
227
+ }
228
+ // 4. Build loader.
229
+ const loader = new PackedLoader(transport, index);
230
+ // 5. Return Quiver via internal factory.
231
+ return Quiver._fromLoader(manifest.name, catalogRaw, loader);
232
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Internal parser/validator for Quiver.yaml files.
3
+ *
4
+ * Uses the `yaml` npm package for robust YAML parsing. Quiver.yaml has a
5
+ * simple two-field schema (name, description), but using a proper YAML parser
6
+ * ensures correct handling of quoting, escaping, and multi-line strings.
7
+ */
8
+ export interface QuiverMeta {
9
+ name: string;
10
+ description?: string;
11
+ }
12
+ /**
13
+ * Parses and validates Quiver.yaml contents.
14
+ *
15
+ * Throws `QuiverError('quiver_invalid')` on:
16
+ * - YAML parse failure
17
+ * - Missing or non-string `name`
18
+ * - `name` fails charset validation [A-Za-z0-9_-]+
19
+ * - Unknown fields (strict)
20
+ * - `description` present but not a string
21
+ */
22
+ export declare function parseQuiverYaml(raw: string | Uint8Array): QuiverMeta;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Internal parser/validator for Quiver.yaml files.
3
+ *
4
+ * Uses the `yaml` npm package for robust YAML parsing. Quiver.yaml has a
5
+ * simple two-field schema (name, description), but using a proper YAML parser
6
+ * ensures correct handling of quoting, escaping, and multi-line strings.
7
+ */
8
+ import { parse as parseYaml } from "yaml";
9
+ import { QuiverError } from "./errors.js";
10
+ const NAME_RE = /^[A-Za-z0-9_-]+$/;
11
+ const KNOWN_FIELDS = new Set(["name", "description"]);
12
+ /**
13
+ * Parses and validates Quiver.yaml contents.
14
+ *
15
+ * Throws `QuiverError('quiver_invalid')` on:
16
+ * - YAML parse failure
17
+ * - Missing or non-string `name`
18
+ * - `name` fails charset validation [A-Za-z0-9_-]+
19
+ * - Unknown fields (strict)
20
+ * - `description` present but not a string
21
+ */
22
+ export function parseQuiverYaml(raw) {
23
+ const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
24
+ let parsed;
25
+ try {
26
+ parsed = parseYaml(text);
27
+ }
28
+ catch (err) {
29
+ throw new QuiverError("quiver_invalid", `Quiver.yaml: YAML parse failure — ${err instanceof Error ? err.message : String(err)}`, { cause: err });
30
+ }
31
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
32
+ throw new QuiverError("quiver_invalid", `Quiver.yaml: expected a mapping at the top level, got ${Array.isArray(parsed) ? "array" : String(parsed)}`);
33
+ }
34
+ const doc = parsed;
35
+ // Check for unknown fields (strict mode)
36
+ for (const key of Object.keys(doc)) {
37
+ if (!KNOWN_FIELDS.has(key)) {
38
+ throw new QuiverError("quiver_invalid", `Quiver.yaml: unknown field "${key}" — only "name" and "description" are valid in V1`);
39
+ }
40
+ }
41
+ // Validate `name`
42
+ if (!("name" in doc)) {
43
+ throw new QuiverError("quiver_invalid", `Quiver.yaml: required field "name" is missing`);
44
+ }
45
+ if (typeof doc["name"] !== "string") {
46
+ throw new QuiverError("quiver_invalid", `Quiver.yaml: "name" must be a string, got ${typeof doc["name"]}`);
47
+ }
48
+ const name = doc["name"];
49
+ if (!NAME_RE.test(name)) {
50
+ throw new QuiverError("quiver_invalid", `Quiver.yaml: "name" value "${name}" contains invalid characters — only [A-Za-z0-9_-] are allowed`);
51
+ }
52
+ // Validate optional `description`
53
+ if ("description" in doc && doc["description"] !== undefined) {
54
+ if (typeof doc["description"] !== "string") {
55
+ throw new QuiverError("quiver_invalid", `Quiver.yaml: "description" must be a string if present, got ${typeof doc["description"]}`);
56
+ }
57
+ }
58
+ const meta = { name };
59
+ if (typeof doc["description"] === "string") {
60
+ meta.description = doc["description"];
61
+ }
62
+ return meta;
63
+ }