@quillmark/quiver 0.38.1 → 0.39.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/MIGRATION.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Upgrade notes per range. The [CHANGELOG](CHANGELOG.md) is the full record; this page is the subset that breaks a working consumer, ordered by how late you find out.
4
4
 
5
+ ## 0.38 → 0.39
6
+
7
+ ### Rebuild every artifact
8
+
9
+ A built quiver is `quiver.json` beside one `<name>@<x.y.z>.<digest>.quill` bundle per quill, each carrying its quill's fonts; `fonts/` is gone, and `quiver.json` and `latest.json` state format 3. A format-2 artifact under 0.39 is refused as `quiver_invalid` naming the rebuild; a 0.39 artifact under an older reader is refused with the upgrade named. Re-run `build`; nothing in the source layout changes.
10
+
11
+ `quillkit` packs and reads with the `@quillmark/quiver` it carries, so a quillkit deploy moves to format 3 with the quillkit release that carries 0.39, and nothing in the collection pins this package.
12
+
13
+ A host rule naming `fonts/*` or `*.zip` under a built quiver names `*.quill` now. A server whose static handler types files by extension serves `.quill` as `application/vnd.quillmark.quill+zip` or `application/octet-stream`; the loader reads the bytes whatever the type.
14
+
15
+ ### Runtime, not build time
16
+
17
+ **A quill whose tree holds a file name the `.quill` format refuses fails `build`**: one that holds `\`, `:` or NUL, which a filesystem on Linux or macOS allows. Rename the file.
18
+
19
+ **A bundle the `.quill` format refuses fails `getQuill` with the `QuillmarkError` `Quill.fromBundle` throws**, its diagnostic a `quill::bundle_*` code, where an over-budget or misnamed entry was a `QuiverError` coded `quiver_invalid`. Code that sorts a `getQuill` failure by `QuiverError.code` reads `diagnostics` off the error `isQuillmarkError` narrows. The `@quillmark/wasm` peer floor is `>=0.133.0-0`.
20
+
5
21
  ## 0.29 → 0.30
6
22
 
7
23
  ### Rebuild every artifact, and move quiver and quillkit together
package/README.md CHANGED
@@ -37,17 +37,17 @@ const root = dirname(createRequire(import.meta.url).resolve('@org/my-quiver/Quiv
37
37
  const quiver = await fromDir(root);
38
38
  const engine = new Engine();
39
39
 
40
- const { Document } = await init(); // the gate is the only door to Document
40
+ const { Document } = await init(); // Document is exported only through init()
41
41
  const doc = Document.fromMarkdown(markdownString);
42
42
  const quill = await quiver.getQuill(doc.quillRef);
43
43
  const result = await engine.render(quill, doc, { format: 'pdf' });
44
44
  ```
45
45
 
46
- `getQuill(ref)` is the only way to obtain a quill from a quiver, and the only entry point most consumers need. It accepts selector refs (`"memo"`, `"memo@1.0"`) and canonical ones (`"memo@1.0.0"`), resolves the selector, materializes the quill via `Quill.fromTree`, and caches one instance per canonical ref for the quiver's lifetime; concurrent calls for the same ref coalesce into a single load. It awaits `init()` itself, so instantiating the core is not a precondition of calling it.
46
+ `getQuill(ref)` is the only way to obtain a quill from a quiver, and the only entry point most consumers need. It accepts selector refs (`"memo"`, `"memo@1.0"`) and canonical ones (`"memo@1.0.0"`), resolves the selector, materializes the quill via `Quill.fromBundle` (a built quiver) or `Quill.fromTree` (a source one), and caches one instance per canonical ref for the quiver's lifetime; concurrent calls for the same ref coalesce into a single load. It awaits `init()` itself, so instantiating the core is not a precondition of calling it.
47
47
 
48
48
  That quill is **borrowed, not owned**: every caller asking for that ref gets the same instance, so `free()` on it hands the next caller a freed handle. Code that owns its quill mints one from `(await quiver.getQuill(ref)).toTree()` and frees that.
49
49
 
50
- This package produces quills; `@quillmark/wasm` renders them. A quill from `getQuill` is engine-free portable data — schema inspection, validation, blueprint access, `seedDocument()` — and passes straight to `engine.render(quill, doc)`, which routes on `quill.backendId`, loads that backend, clones both handles into its memory, renders, and frees the clones. There is no boundary-crossing step to perform. `Engine.render`, `open` and `supportedFormats` are **async**. The canonical `Quill` / `Document` / `Engine` types are not re-exported here; import them from the `@quillmark/wasm` peer, their single source of truth.
50
+ This package produces quills; `@quillmark/wasm` renders them. A quill from `getQuill` is engine-free portable data — schema inspection, validation, `authoringSchema`, `seedDocument()` — and passes straight to `engine.render(quill, doc)`, which routes on `quill.backendId`, loads that backend, clones both handles into its memory, renders, and frees the clones. There is no boundary-crossing step to perform. `Engine.render`, `open` and `supportedFormats` are **async**. The canonical `Quill` / `Document` / `Engine` types are not re-exported here; import them from the `@quillmark/wasm` peer, their single source of truth.
51
51
 
52
52
  One narrower verb sits beside `getQuill`: `resolve(ref)` returns the canonical ref without materializing anything, and is **sync** (the catalog is in memory from the moment the quiver is built).
53
53
 
@@ -82,7 +82,7 @@ import { Quiver } from '@quillmark/quiver';
82
82
  const quiver = await Quiver.fromBuiltUrl('/quivers/my-quiver/');
83
83
  const engine = new Engine();
84
84
 
85
- const { Document } = await init(); // the gate is the only door to Document
85
+ const { Document } = await init(); // Document is exported only through init()
86
86
  const doc = Document.fromMarkdown(markdownString);
87
87
  const quill = await quiver.getQuill(doc.quillRef);
88
88
  const result = await engine.render(quill, doc, { format: 'pdf' });
@@ -103,7 +103,7 @@ const quiver = await fromBuiltDir('./static/quills/my-quiver');
103
103
 
104
104
  ## Server-side runtime (no filesystem)
105
105
 
106
- A serverless function's packed artifact is not on a path the invocation can read, so `fromBuiltDir` cannot see it. Hand over the bytes instead, keyed by artifact-relative path as `build` writes them (`quiver.json`, `<name>@<x.y.z>.<digest>.zip`, `fonts/<hash>`):
106
+ A serverless function's packed artifact is not on a path the invocation can read, so `fromBuiltDir` cannot see it. Hand over the bytes instead, keyed by artifact-relative path as `build` writes them (`quiver.json`, `<name>@<x.y.z>.<digest>.quill`):
107
107
 
108
108
  ```ts
109
109
  import { Quiver } from '@quillmark/quiver';
@@ -115,9 +115,9 @@ Nothing is fetched, so nothing self-fetches over your own load balancer. The map
115
115
 
116
116
  ## The `quiver.json` index
117
117
 
118
- `Quiver.fromBuiltUrl(url)` first fetches `<url>/quiver.json`: the format and the catalog, naming each quill's bundle and fonts. Every other name carries the digest of its own bytes, so a changed file is a changed name and is safe to cache forever; `quiver.json` is not, and a cache layer serving a stale one silently pins the client to the old catalog. It is therefore the one request fetched `no-cache` (revalidate with the origin; a 304 still serves from disk), and every other request is `force-cache`, read once more with `reload` if what the cache answered fails. Both are the browser layer only: a stale CDN edge is answered by that host's cache headers, and `quillkit`'s README states the whole contract for a deploy serving a client over one.
118
+ `Quiver.fromBuiltUrl(url)` first fetches `<url>/quiver.json`: the format and the catalog, naming each quill's bundle. Every other name carries the digest of its own bytes, so a changed file is a changed name and is safe to cache forever; `quiver.json` is not, and a cache layer serving a stale one silently pins the client to the old catalog. It is therefore the one request fetched `no-cache` (revalidate with the origin; a 304 still serves from disk), and every other request is `force-cache`, read once more with `reload` if what the cache answered fails. Both are the browser layer only: a stale CDN edge is answered by that host's cache headers, and `quillkit`'s README states the whole contract for a deploy serving a client over one.
119
119
 
120
- A `getQuill` reads one bundle and the fonts it names, nothing of another quill. Fonts are stored once by hash, so a release that edits a template re-downloads that quill's bundle and not its fonts. A tab still holding the previous release's `quiver.json` rereads it once when a read fails.
120
+ A `getQuill` reads one bundle, nothing of another quill. Each bundle is a `.quill` bundle: the quill's whole tree as one zip, fonts included, `Quill.yaml` at the root — the file `Quill.fromBundle` in `@quillmark/wasm`, `Quill.from_bundle` in Python and the `quillmark` CLI read. A release that edits a template re-downloads that quill's bundle, fonts and all. A tab still holding the previous release's `quiver.json` rereads it once when a read fails.
121
121
 
122
122
  ## What a quiver is trusted to be
123
123
 
@@ -127,7 +127,7 @@ Nothing checks fetched bytes against their names: the digests are there for cach
127
127
 
128
128
  ## Error handling
129
129
 
130
- Every error is a `QuiverError` carrying a `code` from a closed set — `invalid_ref`, `quill_not_found`, `quiver_invalid`, `transport_error` — a human-readable `message`, and the offending `ref` where there is one.
130
+ Every error is a `QuiverError` carrying a `code` from a closed set — `invalid_ref`, `quill_not_found`, `quiver_invalid`, `transport_error` — a human-readable `message`, and the offending `ref` where there is one. A quill that does not load, a bundle the `.quill` format refuses among them, is the exception: `getQuill` throws what `Quill.fromBundle` or `Quill.fromTree` threw, a `QuillmarkError` carrying its `diagnostics`.
131
131
 
132
132
  ```ts
133
133
  import { QuiverError } from '@quillmark/quiver';
@@ -169,15 +169,15 @@ A consumer reaches an installed quiver by resolving `<specifier>/Quiver.yaml`, w
169
169
 
170
170
  ## Authoring is [quillkit](../quillkit#readme)'s
171
171
 
172
- This package is a library: it loads a quiver and packs one, and it has no CLI. The verbs a quill author runs (gate, pack, look at, deploy) are `quillkit`'s, and it resolves this package out of the collection's own `node_modules`. Depend on it here and the version you pin is the format your quiver is packed in:
172
+ This package is a library: it loads a quiver and packs one, and it has no CLI. The verbs a quill author runs (start, gate, look at, build) are `quillkit`'s, which carries this package at an exact version, so a quiver installs the tool alone and the format it is packed in is the one quillkit's clients read:
173
173
 
174
174
  ```sh
175
- npm install --save-dev @quillmark/quiver @quillmark/wasm quillkit
175
+ npm install --save-dev quillkit
176
176
  ```
177
177
 
178
178
  ```jsonc
179
179
  // package.json
180
180
  {
181
- "scripts": { "test": "quillkit test" }
181
+ "scripts": { "test": "quillkit check" }
182
182
  }
183
183
  ```
package/dist/build.d.ts CHANGED
@@ -20,11 +20,9 @@ export interface BuildOptions {
20
20
  * Output layout. Every name but the two JSON documents carries the SHA-256 of what it
21
21
  * names, which is what makes it safe to cache forever:
22
22
  * outDir/
23
- * quiver.json # the format and the catalog
24
- * latest.json # the format alone, for a reader of format 1
25
- * <name>@<version>.<sha256:32>.zip # one bundle per quill
26
- * fonts/
27
- * <sha256> # dehydrated font bytes (full hash, no ext)
23
+ * quiver.json # the format and the catalog
24
+ * latest.json # the format alone, for a reader of format 1
25
+ * <name>@<version>.<sha256:32>.quill # one `.quill` bundle per quill, fonts and all
28
26
  *
29
27
  * A generation is assembled beside outDir and moved in whole, so a reader
30
28
  * fetching mid-build reads the previous one rather than a torn tree, and a
package/dist/build.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * `node:crypto` into a browser bundle.
7
7
  */
8
8
  import { QuiverError } from './errors.js';
9
- import { packFiles } from './bundle.js';
9
+ import { BUNDLE_EXTENSION, packFiles } from './bundle.js';
10
10
  import { FORMAT, INDEX, LEGACY_POINTER } from './format.js';
11
11
  import { isFont } from './signature.js';
12
12
  import { isDraft } from './semver.js';
@@ -24,7 +24,7 @@ const stagesOf = (out) => ({
24
24
  /**
25
25
  * The build deletes each of the three trees it owns, so an outDir that is, or contains,
26
26
  * the source quiver or the working directory deletes the thing the caller was building
27
- * from. `quillkit build --out .` and a mistyped `--out ..` are both one keystroke away,
27
+ * from. `quillkit build quiver --out .` and a mistyped `--out ..` are both one keystroke away,
28
28
  * and the failure is unrecoverable, so an outDir that owns the caller is refused up
29
29
  * front. The staging siblings are checked too: they are named off outDir and cleared
30
30
  * with it, so a caller who avoids one spelling has not avoided the others.
@@ -57,11 +57,9 @@ function assertSafeOutDir(path, sourceDir, outDir) {
57
57
  * Output layout. Every name but the two JSON documents carries the SHA-256 of what it
58
58
  * names, which is what makes it safe to cache forever:
59
59
  * outDir/
60
- * quiver.json # the format and the catalog
61
- * latest.json # the format alone, for a reader of format 1
62
- * <name>@<version>.<sha256:32>.zip # one bundle per quill
63
- * fonts/
64
- * <sha256> # dehydrated font bytes (full hash, no ext)
60
+ * quiver.json # the format and the catalog
61
+ * latest.json # the format alone, for a reader of format 1
62
+ * <name>@<version>.<sha256:32>.quill # one `.quill` bundle per quill, fonts and all
65
63
  *
66
64
  * A generation is assembled beside outDir and moved in whole, so a reader
67
65
  * fetching mid-build reads the previous one rather than a torn tree, and a
@@ -113,44 +111,26 @@ export async function buildQuiver(sourceDir, outDir, options = {}) {
113
111
  async function packGeneration() {
114
112
  try {
115
113
  await rm(stage, { recursive: true, force: true });
116
- await mkdir(join(stage, 'fonts'), { recursive: true });
114
+ await mkdir(stage, { recursive: true });
117
115
  }
118
116
  catch (err) {
119
117
  throw new QuiverError('transport_error', `Failed to prepare output directory "${outDir}": ${err.message}`, { cause: err });
120
118
  }
121
119
  const quills = [];
122
- const stored = new Set();
123
120
  for (const [quillName, versions] of catalog) {
124
121
  for (const version of versions) {
125
122
  if (!options.drafts && isDraft(version))
126
123
  continue;
127
124
  const quillDir = join(sourceDir, 'quills', quillName, version);
128
125
  const tree = await readQuillTree(quillDir);
129
- const fonts = {};
130
126
  const contentRecord = {};
131
127
  for (const [rel, bytes] of tree) {
132
- if (!FONT_EXT.test(rel)) {
133
- contentRecord[rel] = bytes;
134
- continue;
135
- }
136
- // The loader refuses what does not open as a font, so the build refuses it
137
- // first, where the author it names can do something about it.
138
- if (!isFont(bytes, rel)) {
128
+ // Typst substitutes a font it cannot parse without a word, so a font file
129
+ // that is not one is refused where the author it names can fix it.
130
+ if (FONT_EXT.test(rel) && !isFont(bytes, rel)) {
139
131
  throw new QuiverError('quiver_invalid', `Quill "${quillName}@${version}": "${rel}" does not open as the font its extension names`, { quiverName: meta.name, version });
140
132
  }
141
- // Full width: fonts are keyed by hash, so two distinct fonts sharing a
142
- // prefix would merge into one file.
143
- const hash = createHash('sha256').update(bytes).digest('hex');
144
- fonts[rel] = hash;
145
- if (stored.has(hash))
146
- continue;
147
- try {
148
- await writeFile(join(stage, 'fonts', hash), bytes);
149
- }
150
- catch (err) {
151
- throw new QuiverError('transport_error', `Failed to write font "${join(outDir, 'fonts', hash)}": ${err.message}`, { cause: err });
152
- }
153
- stored.add(hash);
133
+ contentRecord[rel] = bytes;
154
134
  }
155
135
  // The budget `bundle.ts` carries is spent here as well as at the read, so
156
136
  // a quill no loader would take is refused by the build that packs it,
@@ -166,14 +146,14 @@ export async function buildQuiver(sourceDir, outDir, options = {}) {
166
146
  .update(zipBytes)
167
147
  .digest('hex')
168
148
  .slice(0, NAME_DIGEST_LENGTH);
169
- const bundleName = `${quillName}@${version}.${bundleHash}.zip`;
149
+ const bundleName = `${quillName}@${version}.${bundleHash}.${BUNDLE_EXTENSION}`;
170
150
  try {
171
151
  await writeFile(join(stage, bundleName), zipBytes);
172
152
  }
173
153
  catch (err) {
174
154
  throw new QuiverError('transport_error', `Failed to write bundle "${join(outDir, bundleName)}": ${err.message}`, { cause: err });
175
155
  }
176
- quills.push({ name: quillName, version, bundle: bundleName, fonts });
156
+ quills.push({ name: quillName, version, bundle: bundleName });
177
157
  }
178
158
  }
179
159
  // The format is stamped here and read first, so a client older than the tree says so
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Built-quiver loader: reads `quiver.json` through a reader, validates it, and hands back
3
- * a Quiver whose loader reads bundles and fonts on demand.
3
+ * a Quiver whose loader reads one `.quill` bundle per quill on demand.
4
4
  *
5
5
  * Package-internal, and browser-safe: no static `node:` imports at any level.
6
6
  */
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Built-quiver loader: reads `quiver.json` through a reader, validates it, and hands back
3
- * a Quiver whose loader reads bundles and fonts on demand.
3
+ * a Quiver whose loader reads one `.quill` bundle per quill on demand.
4
4
  *
5
5
  * Package-internal, and browser-safe: no static `node:` imports at any level.
6
6
  */
7
7
  import { QuiverError } from './errors.js';
8
- import { unpackFiles } from './bundle.js';
9
8
  import { isQuillName } from './ref.js';
10
9
  import { isCanonicalSemver, compareSemver } from './semver.js';
11
10
  import { FORMAT, INDEX } from './format.js';
12
- import { isFont, isZip } from './signature.js';
11
+ import { isZip } from './signature.js';
13
12
  import { createQuiver } from './quiver.js';
14
13
  export function httpReader(base) {
15
14
  const root = base.endsWith('/') ? base : `${base}/`;
@@ -44,10 +43,9 @@ export function filesReader(files) {
44
43
  return bytes;
45
44
  };
46
45
  }
47
- // The shapes `build` writes. Neither admits a separator, so a validated name read off
46
+ // The shape `build` writes. It admits no separator, so a validated name read off
48
47
  // `quiver.json` stays inside the artifact on every reader.
49
- const BUNDLE_RE = /^[A-Za-z0-9_-]+@[0-9]+\.[0-9]+\.[0-9]+\.[0-9a-f]+\.zip$/;
50
- const FONT_HASH_RE = /^[0-9a-f]{64}$/;
48
+ const BUNDLE_RE = /^[A-Za-z0-9_-]+@[0-9]+\.[0-9]+\.[0-9]+\.[0-9a-f]+\.quill$/;
51
49
  /** Bytes a host answered with that are not the file named: a `transport_error`, so the
52
50
  * reread below gets its turn. */
53
51
  function refused(path, what) {
@@ -72,7 +70,6 @@ async function readChecked(read, path, opens, what) {
72
70
  class BuiltLoader {
73
71
  read;
74
72
  entries;
75
- #fonts = new Map();
76
73
  #rereading;
77
74
  constructor(read, entries) {
78
75
  this.read = read;
@@ -84,11 +81,11 @@ class BuiltLoader {
84
81
  * names other files. A reread refusing the index is the error that explains the
85
82
  * failure, and is the one thrown.
86
83
  */
87
- async loadTree(name, version) {
84
+ async load(name, version) {
88
85
  const key = `${name}@${version}`;
89
86
  const entry = this.entries.get(key);
90
87
  try {
91
- return await this.#treeOf(entry);
88
+ return await this.#bundleOf(entry);
92
89
  }
93
90
  catch (err) {
94
91
  let index;
@@ -99,9 +96,9 @@ class BuiltLoader {
99
96
  throw reread instanceof QuiverError && reread.code === 'quiver_invalid' ? reread : err;
100
97
  }
101
98
  const fresh = index.entries.get(key);
102
- if (fresh === undefined || sameFiles(fresh, entry))
99
+ if (fresh === undefined || fresh.bundle === entry.bundle)
103
100
  throw err;
104
- return this.#treeOf(fresh);
101
+ return this.#bundleOf(fresh);
105
102
  }
106
103
  }
107
104
  /** One reread in flight, taking every entry both generations carry. */
@@ -118,30 +115,10 @@ class BuiltLoader {
118
115
  this.#rereading = undefined;
119
116
  }));
120
117
  }
121
- async #treeOf(entry) {
122
- const zip = await readChecked(this.read, entry.bundle, isZip, 'a zip');
123
- const files = unpackFiles(zip);
124
- await Promise.all(Object.entries(entry.fonts).map(async ([path, hash]) => {
125
- files[path] = await this.#font(hash, path);
126
- }));
127
- return new Map(Object.entries(files));
128
- }
129
- /** One read per hash, coalesced; a failure is evicted so a retry reads again. */
130
- #font(hash, rel) {
131
- let bytes = this.#fonts.get(hash);
132
- if (bytes === undefined) {
133
- bytes = readChecked(this.read, `fonts/${hash}`, (font) => isFont(font, rel), 'a font').catch((err) => {
134
- this.#fonts.delete(hash);
135
- throw err;
136
- });
137
- this.#fonts.set(hash, bytes);
138
- }
139
- return bytes;
118
+ #bundleOf(entry) {
119
+ return readChecked(this.read, entry.bundle, isZip, 'a zip');
140
120
  }
141
121
  }
142
- function sameFiles(a, b) {
143
- return a.bundle === b.bundle && JSON.stringify(a.fonts) === JSON.stringify(b.fonts);
144
- }
145
122
  async function readIndex(read) {
146
123
  let bytes;
147
124
  try {
@@ -176,6 +153,10 @@ function parseIndex(raw) {
176
153
  throw new QuiverError('quiver_invalid', `This quiver is built in format ${format} and this loader reads ${FORMAT}. ` +
177
154
  `Upgrade @quillmark/quiver, or for a served client the client itself, which carries the copy that reads this.`);
178
155
  }
156
+ if (format < FORMAT) {
157
+ throw new QuiverError('quiver_invalid', `This quiver is built in format ${format} and this loader reads ${FORMAT}. ` +
158
+ `Rebuild it with a @quillmark/quiver that writes format ${FORMAT}, in the collection where it is packed.`);
159
+ }
179
160
  const name = obj['name'];
180
161
  if (typeof name !== 'string' || name.length === 0) {
181
162
  throw new QuiverError('quiver_invalid', `${INDEX} must have a non-empty string "name" field`);
@@ -214,22 +195,11 @@ function parseIndex(raw) {
214
195
  if (typeof bundle !== 'string' || !BUNDLE_RE.test(bundle)) {
215
196
  throw new QuiverError('quiver_invalid', `${at}.bundle is invalid: "${String(bundle)}"`);
216
197
  }
217
- const declared = e['fonts'];
218
- if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
219
- throw new QuiverError('quiver_invalid', `${at}.fonts must be an object`);
220
- }
221
- const fonts = {};
222
- for (const [path, hash] of Object.entries(declared)) {
223
- if (typeof hash !== 'string' || !FONT_HASH_RE.test(hash)) {
224
- throw new QuiverError('quiver_invalid', `${at}.fonts["${path}"] is not a font hash: ${JSON.stringify(hash)}`);
225
- }
226
- fonts[path] = hash;
227
- }
228
198
  const key = `${quillName}@${version}`;
229
199
  if (entries.has(key)) {
230
200
  throw new QuiverError('quiver_invalid', `Duplicate quill entry in ${INDEX}: "${key}"`);
231
201
  }
232
- entries.set(key, { name: quillName, version, bundle, fonts });
202
+ entries.set(key, { name: quillName, version, bundle });
233
203
  }
234
204
  return { name, description, entries };
235
205
  }
package/dist/bundle.d.ts CHANGED
@@ -1,17 +1,13 @@
1
1
  /**
2
- * Zip utilities — browser-safe (uses fflate only).
2
+ * The `.quill` bundle, written through fflate. The format is Quillmark's (quill-bundle.md
3
+ * in the quillmark repository): one quill's tree as one zip, `Quill.yaml` at the root.
4
+ * `Quill.fromBundle` reads it.
3
5
  */
6
+ /** The extension a `.quill` bundle's name ends in. */
7
+ export declare const BUNDLE_EXTENSION = "quill";
4
8
  /**
5
9
  * Pack a flat file map into a deterministic zip.
6
10
  * Keys are sorted before zipping so insertion order doesn't affect output.
7
- * Throws `quiver_invalid` where the budget is spent.
11
+ * Throws `quiver_invalid` where the budget is spent or a name is one the format refuses.
8
12
  */
9
13
  export declare function packFiles(files: Record<string, Uint8Array>): Uint8Array;
10
- /**
11
- * Unpack a zip into a flat file map.
12
- * Returns { path: Uint8Array } for every file entry in the archive.
13
- * Throws `quiver_invalid` where the budget is spent. fflate filters and inflates one
14
- * entry per pass, so the entries the budget already took are resident when it throws —
15
- * bounded, being what fit inside it.
16
- */
17
- export declare function unpackFiles(data: Uint8Array): Record<string, Uint8Array>;
package/dist/bundle.js CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Zip utilities — browser-safe (uses fflate only).
2
+ * The `.quill` bundle, written through fflate. The format is Quillmark's (quill-bundle.md
3
+ * in the quillmark repository): one quill's tree as one zip, `Quill.yaml` at the root.
4
+ * `Quill.fromBundle` reads it.
3
5
  */
4
- import { zipSync, unzipSync } from 'fflate';
6
+ import { zipSync } from 'fflate';
5
7
  import { QuiverError } from './errors.js';
8
+ /** The extension a `.quill` bundle's name ends in. */
9
+ export const BUNDLE_EXTENSION = 'quill';
6
10
  /**
7
11
  * Fixed epoch mtime for deterministic zip output.
8
12
  *
@@ -15,29 +19,31 @@ import { QuiverError } from './errors.js';
15
19
  */
16
20
  const ZIP_EPOCH = new Date(1980, 0, 1, 0, 0, 0, 0);
17
21
  /**
18
- * What a bundle may hold. Deflate tops out near 1032:1, so a megabyte of zip is a
19
- * gigabyte resident and the bundle is what a reader inflates to find that out.
20
- *
21
- * The ceiling is a constant rather than something a consumer raises, because a quiver
22
- * is a dependency pinned like any other (README, "What a quiver is trusted to be") and
23
- * what a budget bounds there is a build that packed something enormous. Both ends spend
24
- * it, so an artifact no loader would read is refused where it is written.
25
- *
26
- * Fonts are not in here: `build` writes them under `fonts/` and `quiver.json` names them,
27
- * leaving a bundle its markdown, its Typst, and what those inline.
22
+ * What a bundle may hold: the `.quill` format's budget, which every Quillmark runtime
23
+ * spends on the same three bounds. A constant rather than something a consumer raises,
24
+ * because the format fixes it. The reader spends it too, so an artifact no loader would
25
+ * read is refused where it is written.
28
26
  */
29
27
  const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
30
- const MAX_ENTRY_BYTES = 16 * 1024 * 1024;
28
+ const MAX_ENTRY_BYTES = 32 * 1024 * 1024;
31
29
  const MAX_BUNDLE_ENTRIES = 2048;
32
30
  /**
33
- * The budget, spent one entry at a time. Throws `quiver_invalid`.
34
- *
35
- * Unpacking spends the sizes the central directory declares, and those are the sizes
36
- * fflate allocates: each entry inflates into a buffer of exactly its declared size,
37
- * which never grows. So a header cannot buy more than it declares — it can only
38
- * understate itself and truncate its own entry, which is a corrupt bundle rather than an
39
- * allocation.
31
+ * The `.quill` format's entry names: relative, `/`-separated, and no segment empty, `.`
32
+ * or `..`, nor holding `\`, `:` or NUL. Throws `quiver_invalid`.
40
33
  */
34
+ function checkPath(name) {
35
+ const why = name.startsWith('/')
36
+ ? 'is absolute'
37
+ : /[\\:\0]/.test(name)
38
+ ? 'holds a \\, : or NUL'
39
+ : name.split('/').some((s) => s === '' || s === '.' || s === '..')
40
+ ? 'has an empty, "." or ".." segment'
41
+ : undefined;
42
+ if (why !== undefined) {
43
+ throw new QuiverError('quiver_invalid', `Bundled file "${name}" ${why}`);
44
+ }
45
+ }
46
+ /** The budget, spent one entry at a time. Throws `quiver_invalid`. */
41
47
  function bundleBudget() {
42
48
  let total = 0;
43
49
  let count = 0;
@@ -57,7 +63,7 @@ function bundleBudget() {
57
63
  /**
58
64
  * Pack a flat file map into a deterministic zip.
59
65
  * Keys are sorted before zipping so insertion order doesn't affect output.
60
- * Throws `quiver_invalid` where the budget is spent.
66
+ * Throws `quiver_invalid` where the budget is spent or a name is one the format refuses.
61
67
  */
62
68
  export function packFiles(files) {
63
69
  const spend = bundleBudget();
@@ -65,24 +71,9 @@ export function packFiles(files) {
65
71
  const input = {};
66
72
  for (const key of sorted) {
67
73
  const bytes = files[key];
74
+ checkPath(key);
68
75
  spend(key, bytes.length);
69
76
  input[key] = [bytes, { mtime: ZIP_EPOCH }];
70
77
  }
71
78
  return zipSync(input, { level: 6 });
72
79
  }
73
- /**
74
- * Unpack a zip into a flat file map.
75
- * Returns { path: Uint8Array } for every file entry in the archive.
76
- * Throws `quiver_invalid` where the budget is spent. fflate filters and inflates one
77
- * entry per pass, so the entries the budget already took are resident when it throws —
78
- * bounded, being what fit inside it.
79
- */
80
- export function unpackFiles(data) {
81
- const spend = bundleBudget();
82
- return unzipSync(data, {
83
- filter: ({ name, originalSize }) => {
84
- spend(name, originalSize);
85
- return true;
86
- }
87
- });
88
- }
package/dist/format.d.ts CHANGED
@@ -2,12 +2,12 @@
2
2
  * other name is read off. */
3
3
  export declare const INDEX = "quiver.json";
4
4
  /**
5
- * The artifact's format, stamped into `quiver.json` and refused to be read past (QUIVER
6
- * §"The index"). A change a reader assuming the previous shape would misread moves it, and
7
- * so does the first added field, which format-2 readers before 0.38 refuse; from 3 on, an
8
- * added field a reader can skip does not.
5
+ * The artifact's format, stamped into `quiver.json` and read at exactly this value (QUIVER
6
+ * §"The index"). A change a reader assuming the previous shape would misread moves it; an
7
+ * added field a reader can skip does not. Format 3 is one `.quill` bundle per quill and no
8
+ * `fonts/`.
9
9
  */
10
- export declare const FORMAT = 2;
10
+ export declare const FORMAT = 3;
11
11
  /**
12
12
  * What a reader of format 1 fetches first. It reads `format` off it before anything else,
13
13
  * so a file carrying this format and nothing more is refused there with the upgrade named,
package/dist/format.js CHANGED
@@ -2,12 +2,12 @@
2
2
  * other name is read off. */
3
3
  export const INDEX = 'quiver.json';
4
4
  /**
5
- * The artifact's format, stamped into `quiver.json` and refused to be read past (QUIVER
6
- * §"The index"). A change a reader assuming the previous shape would misread moves it, and
7
- * so does the first added field, which format-2 readers before 0.38 refuse; from 3 on, an
8
- * added field a reader can skip does not.
5
+ * The artifact's format, stamped into `quiver.json` and read at exactly this value (QUIVER
6
+ * §"The index"). A change a reader assuming the previous shape would misread moves it; an
7
+ * added field a reader can skip does not. Format 3 is one `.quill` bundle per quill and no
8
+ * `fonts/`.
9
9
  */
10
- export const FORMAT = 2;
10
+ export const FORMAT = 3;
11
11
  /**
12
12
  * What a reader of format 1 fetches first. It reads `format` off it before anything else,
13
13
  * so a file carrying this format and nothing more is refused there with the upgrade named,
package/dist/node.d.ts CHANGED
@@ -28,7 +28,7 @@ import type { BuildOptions } from './build.js';
28
28
  export declare function fromDir(pathOrFileUrl: string): Promise<Quiver>;
29
29
  /**
30
30
  * Loads a packed (build-output) quiver from a local directory containing
31
- * `quiver.json` and the bundles and fonts written by `build`.
31
+ * `quiver.json` and the `.quill` bundles written by `build`.
32
32
  * Symmetric to `Quiver.fromBuiltUrl(url)` but reads from disk instead of HTTP:
33
33
  * no network required.
34
34
  *
package/dist/node.js CHANGED
@@ -40,7 +40,7 @@ export async function fromDir(pathOrFileUrl) {
40
40
  }
41
41
  /**
42
42
  * Loads a packed (build-output) quiver from a local directory containing
43
- * `quiver.json` and the bundles and fonts written by `build`.
43
+ * `quiver.json` and the `.quill` bundles written by `build`.
44
44
  * Symmetric to `Quiver.fromBuiltUrl(url)` but reads from disk instead of HTTP:
45
45
  * no network required.
46
46
  *
@@ -79,6 +79,6 @@ export async function build(sourceDir, outDir, options) {
79
79
  export { Quiver } from './quiver.js';
80
80
  export { QuiverError } from './errors.js';
81
81
  export { isDraft } from './semver.js';
82
- // Engine types (`Quillmark`, `Quill`, `Document`, `RenderResult`, …) are not
82
+ // The canonical types (`Quill`, `Document`, `Engine`, `RenderResult`, …) are not
83
83
  // re-exported: import them straight from the `@quillmark/wasm` peer dependency,
84
- // which is the single source of truth for the engine contract.
84
+ // which is their single source of truth.
package/dist/quiver.d.ts CHANGED
@@ -8,9 +8,11 @@
8
8
  * the same either way.
9
9
  */
10
10
  import type { Quill } from '@quillmark/wasm';
11
+ /** A quill as a loader holds it: a source quiver's file tree, or a built one's `.quill` bundle. */
12
+ export type QuillSource = Map<string, Uint8Array> | Uint8Array;
11
13
  /** Loader strategy: source or build output. Package-internal. */
12
14
  export interface QuiverLoader {
13
- loadTree(name: string, version: string): Promise<Map<string, Uint8Array>>;
15
+ load(name: string, version: string): Promise<QuillSource>;
14
16
  }
15
17
  /**
16
18
  * The private constructor's only door, closed over by the static block below.
@@ -42,8 +44,8 @@ export declare class Quiver {
42
44
  static fromBuiltUrl(url: string): Promise<Quiver>;
43
45
  /**
44
46
  * Browser-safe factory. Loads build output from the bytes themselves, keyed
45
- * by artifact-relative path (`quiver.json`, `<name>@<x.y.z>.<digest>.zip`,
46
- * `fonts/<hash>`) — the shape `build` writes and `fromBuiltDir` reads back.
47
+ * by artifact-relative path (`quiver.json`, `<name>@<x.y.z>.<digest>.quill`)
48
+ * — the shape `build` writes and `fromBuiltDir` reads back.
47
49
  *
48
50
  * Nothing is fetched, so a runtime whose artifact is not on a path it can
49
51
  * read (a serverless function, a bundler that inlines it, a test) reaches
@@ -71,7 +73,7 @@ export declare class Quiver {
71
73
  *
72
74
  * Sync, and in-memory by construction rather than by implementation: every
73
75
  * loader materializes the catalog as the quiver is built, and `QuiverLoader`
74
- * carries one verb, `loadTree`, which resolution never reaches.
76
+ * carries one verb, `load`, which resolution never reaches.
75
77
  *
76
78
  * Throws:
77
79
  * - `invalid_ref` if ref fails parseQuillRef
@@ -83,7 +85,7 @@ export declare class Quiver {
83
85
  *
84
86
  * The returned quill is materialized from the `@quillmark/wasm` root export
85
87
  * — it is engine-free, portable data suitable for schema inspection,
86
- * validation, blueprint access, and document seeding.
88
+ * validation, authoring-schema access, and document seeding.
87
89
  *
88
90
  * A core `Quill` renders directly: pass it to `engine.render(quill, doc)`.
89
91
  *
@@ -100,7 +102,8 @@ export declare class Quiver {
100
102
  * - `invalid_ref` if ref is malformed
101
103
  * - `quill_not_found` if ref does not match any version in this quiver
102
104
  * - propagates I/O errors from the loader unchanged
103
- * - propagates validation errors from Quill.fromTree() unchanged
105
+ * - propagates errors from Quill.fromTree() or Quill.fromBundle() unchanged,
106
+ * a bundle the format refuses among them as `quill::bundle_*`
104
107
  */
105
108
  getQuill(ref: string): Promise<Quill>;
106
109
  }
package/dist/quiver.js CHANGED
@@ -63,8 +63,8 @@ export class Quiver {
63
63
  }
64
64
  /**
65
65
  * Browser-safe factory. Loads build output from the bytes themselves, keyed
66
- * by artifact-relative path (`quiver.json`, `<name>@<x.y.z>.<digest>.zip`,
67
- * `fonts/<hash>`) — the shape `build` writes and `fromBuiltDir` reads back.
66
+ * by artifact-relative path (`quiver.json`, `<name>@<x.y.z>.<digest>.quill`)
67
+ * — the shape `build` writes and `fromBuiltDir` reads back.
68
68
  *
69
69
  * Nothing is fetched, so a runtime whose artifact is not on a path it can
70
70
  * read (a serverless function, a bundler that inlines it, a test) reaches
@@ -99,7 +99,7 @@ export class Quiver {
99
99
  *
100
100
  * Sync, and in-memory by construction rather than by implementation: every
101
101
  * loader materializes the catalog as the quiver is built, and `QuiverLoader`
102
- * carries one verb, `loadTree`, which resolution never reaches.
102
+ * carries one verb, `load`, which resolution never reaches.
103
103
  *
104
104
  * Throws:
105
105
  * - `invalid_ref` if ref fails parseQuillRef
@@ -124,7 +124,7 @@ export class Quiver {
124
124
  *
125
125
  * The returned quill is materialized from the `@quillmark/wasm` root export
126
126
  * — it is engine-free, portable data suitable for schema inspection,
127
- * validation, blueprint access, and document seeding.
127
+ * validation, authoring-schema access, and document seeding.
128
128
  *
129
129
  * A core `Quill` renders directly: pass it to `engine.render(quill, doc)`.
130
130
  *
@@ -141,7 +141,8 @@ export class Quiver {
141
141
  * - `invalid_ref` if ref is malformed
142
142
  * - `quill_not_found` if ref does not match any version in this quiver
143
143
  * - propagates I/O errors from the loader unchanged
144
- * - propagates validation errors from Quill.fromTree() unchanged
144
+ * - propagates errors from Quill.fromTree() or Quill.fromBundle() unchanged,
145
+ * a bundle the format refuses among them as `quill::bundle_*`
145
146
  */
146
147
  async getQuill(ref) {
147
148
  const canonicalRef = this.resolve(ref);
@@ -156,12 +157,13 @@ export class Quiver {
156
157
  return entry;
157
158
  }
158
159
  /**
159
- * Internal: load the tree + construct via Quill.fromTree. Errors propagate.
160
+ * Internal: load the tree or bundle + construct via Quill.fromTree or
161
+ * Quill.fromBundle. Errors propagate.
160
162
  *
161
- * The tree is held only for the length of this call: the quill cache holds the
162
- * materialized result, and it is the one cache. A second tree cache beside it
163
- * would buy a retry after a `Quill.fromTree` throw its refetch, which is a
164
- * saved round-trip on the path where the quill is broken.
163
+ * The source is held only for the length of this call: the quill cache holds the
164
+ * materialized result, and it is the one cache. A second source cache beside it
165
+ * would buy a retry after a throw its refetch, which is a saved round-trip on the
166
+ * path where the quill is broken.
165
167
  *
166
168
  * Every caller arrives through `resolve` or the catalog itself, so the ref is
167
169
  * catalog-backed by construction and the loader needs no gate.
@@ -172,11 +174,11 @@ export class Quiver {
172
174
  */
173
175
  async #materializeQuill(canonicalRef) {
174
176
  const at = canonicalRef.indexOf('@');
175
- const [{ Quill }, tree] = await Promise.all([
177
+ const [{ Quill }, source] = await Promise.all([
176
178
  init(),
177
- this.#loader.loadTree(canonicalRef.slice(0, at), canonicalRef.slice(at + 1))
179
+ this.#loader.load(canonicalRef.slice(0, at), canonicalRef.slice(at + 1))
178
180
  ]);
179
- return Quill.fromTree(tree);
181
+ return source instanceof Uint8Array ? Quill.fromBundle(source) : Quill.fromTree(source);
180
182
  }
181
183
  }
182
184
  export { create as createQuiver };
package/dist/ref.d.ts CHANGED
@@ -8,7 +8,7 @@ export interface ParsedQuillRef {
8
8
  }
9
9
  /**
10
10
  * Whether `name` is a quill name: `[a-z][a-z0-9_]*`, at most 64 characters, the
11
- * engine's rule. A quiver's own name and every catalog row are held to it.
11
+ * Quillmark's rule. A quiver's own name and every catalog row are held to it.
12
12
  */
13
13
  export declare function isQuillName(name: string): boolean;
14
14
  /**
package/dist/ref.js CHANGED
@@ -3,7 +3,7 @@ const NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
3
3
  const SELECTOR_RE = /^\d+\.\d+(\.\d+)?$/;
4
4
  /**
5
5
  * Whether `name` is a quill name: `[a-z][a-z0-9_]*`, at most 64 characters, the
6
- * engine's rule. A quiver's own name and every catalog row are held to it.
6
+ * Quillmark's rule. A quiver's own name and every catalog row are held to it.
7
7
  */
8
8
  export function isQuillName(name) {
9
9
  return NAME_RE.test(name);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * TrueType, OpenType or a collection of either; WOFF or WOFF2 where `path` names one.
3
- * The engine parses sfnt alone, so a `.ttf` holding WOFF bytes is refused.
3
+ * Quillmark parses sfnt alone, so a `.ttf` holding WOFF bytes is refused.
4
4
  */
5
5
  export declare const isFont: (bytes: Uint8Array, path: string) => boolean;
6
6
  export declare const isZip: (bytes: Uint8Array) => boolean;
package/dist/signature.js CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
- * The leading bytes of what an artifact holds. A host answering a missing name with a
3
- * page of its own answers 200, and nothing else read here tells that page from a font.
2
+ * Leading bytes. A host answering a missing name with a page of its own answers 200, so a
3
+ * bundle is read only once it opens as a zip; and Typst substitutes a font it cannot parse
4
+ * without a word, so `build` packs a font file only once it opens as a font.
4
5
  */
5
6
  const SFNT = ['\0\x01\0\0', 'OTTO', 'true', 'ttcf'];
6
7
  const WOFF = ['wOFF', 'wOF2'];
7
8
  const opens = (bytes, magic) => bytes.length >= magic.length && [...magic].every((c, i) => bytes[i] === c.charCodeAt(0));
8
9
  /**
9
10
  * TrueType, OpenType or a collection of either; WOFF or WOFF2 where `path` names one.
10
- * The engine parses sfnt alone, so a `.ttf` holding WOFF bytes is refused.
11
+ * Quillmark parses sfnt alone, so a `.ttf` holding WOFF bytes is refused.
11
12
  */
12
13
  export const isFont = (bytes, path) => (/\.woff2?$/i.test(path) ? WOFF : SFNT).some((magic) => opens(bytes, magic));
13
14
  export const isZip = (bytes) => opens(bytes, 'PK\x03\x04');
@@ -38,5 +38,5 @@ export declare function readQuillTree(quillDir: string): Promise<Map<string, Uin
38
38
  export declare class SourceLoader implements QuiverLoader {
39
39
  private readonly rootDir;
40
40
  constructor(rootDir: string);
41
- loadTree(name: string, version: string): Promise<Map<string, Uint8Array>>;
41
+ load(name: string, version: string): Promise<Map<string, Uint8Array>>;
42
42
  }
@@ -34,7 +34,7 @@ function refuseUnnamedQuill(quillName, quiverName) {
34
34
  * Refuse a `Quill.yaml` whose `quill.name` or `quill.version` disagrees with the
35
35
  * path it sits at: the catalog serves the path, and a document saved from the quill
36
36
  * names what `Quill.yaml` says. A canonical version has one spelling, so the declared
37
- * one is compared as written. A field absent or not a string is the engine's to report
37
+ * one is compared as written. A field absent or not a string is Quillmark's to report
38
38
  * when the quill loads.
39
39
  */
40
40
  function refuseMisplacedQuill(raw, name, version, quiverName) {
@@ -226,7 +226,7 @@ export class SourceLoader {
226
226
  constructor(rootDir) {
227
227
  this.rootDir = rootDir;
228
228
  }
229
- async loadTree(name, version) {
229
+ async load(name, version) {
230
230
  const quillDir = join(this.rootDir, 'quills', name, version);
231
231
  return readQuillTree(quillDir);
232
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/quiver",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "description": "A quiver of Quillmark quills: load one, pack one, hand out quills.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -42,7 +42,7 @@
42
42
  "NOTICE"
43
43
  ],
44
44
  "peerDependencies": {
45
- "@quillmark/wasm": ">=0.126.0-0"
45
+ "@quillmark/wasm": ">=0.133.0-0"
46
46
  },
47
47
  "dependencies": {
48
48
  "fflate": "^0.8.2",