@quillmark/quiver 0.17.0 → 0.19.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/README.md CHANGED
@@ -62,7 +62,7 @@ import { build } from '@quillmark/quiver/node';
62
62
  await build('./node_modules/@org/my-quiver', './public/quivers/my-quiver');
63
63
  ```
64
64
 
65
- `build` clears its output directory before writing, so it owns that path outright. An `outDir` that is, or contains, the source quiver or the working directory is refused with a `transport_error` rather than deleted.
65
+ `build` owns its output path outright: it assembles a generation in `<outDir>.stage`, moves it in whole, and deletes the one it replaced. A reader fetching mid-build sees the previous generation rather than a torn tree, and a build that throws leaves it serving. An `outDir` that is, or contains, the source quiver or the working directory is refused with a `transport_error` rather than deleted.
66
66
 
67
67
  ```ts
68
68
  // browser runtime
@@ -134,32 +134,17 @@ A consumer reaches an installed quiver by resolving `<specifier>/Quiver.yaml`, w
134
134
  }
135
135
  ```
136
136
 
137
- Gate the quiver in CI so a validation failure surfaces on publish rather than on a consumer's build. `quiver test` loads with `fromDir`, compiles every quill, and renders each quill's example document. One door, so what you run locally is what CI runs.
137
+ ## Authoring is [quillkit](../quillkit#readme)'s
138
138
 
139
- The CLI needs no file. Installing the package links `quiver` into `node_modules/.bin`, which npm puts on PATH for every script:
139
+ 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:
140
140
 
141
141
  ```sh
142
- npm install --save-dev @quillmark/quiver @quillmark/wasm
142
+ npm install --save-dev @quillmark/quiver @quillmark/wasm quillkit
143
143
  ```
144
144
 
145
145
  ```jsonc
146
146
  // package.json
147
147
  {
148
- "scripts": { "test": "quiver test" }
148
+ "scripts": { "test": "quillkit test" }
149
149
  }
150
150
  ```
151
-
152
- It finds the engine itself: a named `engine` export from `quiver.config.js` at the collection root, else `@quillmark/wasm` from your own `node_modules`. `quiver build [--out <dir>]` packs the same source into a servable artifact.
153
-
154
- On vitest, jest or `node:test`, spawn the bin from a test rather than rebuilding the loop against the main API. The gate stays one implementation, and the case gates what CI gates:
155
-
156
- ```ts
157
- // quiver.test.ts
158
- import { execFileSync } from 'node:child_process';
159
-
160
- it('gates the quiver', () => {
161
- execFileSync('quiver', ['test'], { stdio: 'inherit' });
162
- });
163
- ```
164
-
165
- It answers _does it work_, and that is the whole of what this package offers a quill author: _what is it like to use_ is answered by working a real document through the quill live, which is an app rather than a subpath.
package/dist/build.d.ts CHANGED
@@ -11,12 +11,16 @@
11
11
  * Output layout. Every name but the pointer carries the SHA-256 of what it
12
12
  * names, which is what the loader checks on fetch:
13
13
  * outDir/
14
- * latest.json # stable pointer to the current manifest
14
+ * latest.json # the format, and a stable pointer to the manifest
15
15
  * manifest.<sha256:12>.json # hashed manifest
16
16
  * <name>@<version>.<sha256:12>.zip # one bundle per quill
17
17
  * store/
18
18
  * <sha256> # dehydrated font bytes (full hash, no ext)
19
19
  *
20
+ * A generation is assembled beside outDir and moved in whole, so a reader
21
+ * fetching mid-build reads the previous one rather than a torn tree, and a
22
+ * build that throws leaves the previous one serving.
23
+ *
20
24
  * Throws:
21
25
  * - `quiver_invalid` on source validation failures (propagated from scanner)
22
26
  * - `transport_error` on I/O failures, and on an outDir the build would have
package/dist/build.js CHANGED
@@ -8,14 +8,23 @@
8
8
  import { QuiverError } from './errors.js';
9
9
  import { packFiles } from './bundle.js';
10
10
  import { NAME_DIGEST_LENGTH } from './digest.js';
11
+ import { POINTER_FORMAT } from './format.js';
11
12
  /** Font file extensions recognised by the builder (case-insensitive). */
12
13
  const FONT_EXT = /\.(ttf|otf|woff|woff2)$/i;
14
+ /** The two trees a build owns beside its output: where a generation is assembled, and
15
+ * where the one it replaces waits to be deleted. Siblings of outDir, so the swap is a
16
+ * rename rather than a copy across filesystems. */
17
+ const stagesOf = (out) => ({
18
+ stage: `${out}.stage`,
19
+ prev: `${out}.prev`
20
+ });
13
21
  /**
14
- * The build's first act is `rm(outDir, { recursive: true })`, so an outDir that
15
- * is, or contains, the source quiver or the working directory deletes the
16
- * thing the caller was building from. `quiver build --out .` and a mistyped
17
- * `--out ..` are both one keystroke away, and the failure is unrecoverable, so
18
- * an outDir that owns the caller is refused up front.
22
+ * The build deletes each of the three trees it owns, so an outDir that is, or contains,
23
+ * the source quiver or the working directory deletes the thing the caller was building
24
+ * from. `quillkit build --out .` and a mistyped `--out ..` are both one keystroke away,
25
+ * and the failure is unrecoverable, so an outDir that owns the caller is refused up
26
+ * front. The staging siblings are checked too: they are named off outDir and cleared
27
+ * with it, so a caller who avoids one spelling has not avoided the others.
19
28
  *
20
29
  * An outDir *inside* sourceDir stays allowed: the scan reads the source before
21
30
  * any write, and `dist/` under the quiver root is the ordinary layout.
@@ -24,18 +33,19 @@ const FONT_EXT = /\.(ttf|otf|woff|woff2)$/i;
24
33
  */
25
34
  function assertSafeOutDir(path, sourceDir, outDir) {
26
35
  const out = path.resolve(outDir);
27
- /** True when `dir` is `target` or an ancestor of it. */
28
- const owns = (target) => {
29
- const rel = path.relative(out, path.resolve(target));
36
+ const { stage, prev } = stagesOf(out);
37
+ /** True when one of the owned trees is `target` or an ancestor of it. */
38
+ const owns = (target) => [out, stage, prev].some((dir) => {
39
+ const rel = path.relative(dir, path.resolve(target));
30
40
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
31
- };
41
+ });
32
42
  const what = owns(sourceDir)
33
43
  ? 'the source quiver'
34
44
  : owns(process.cwd())
35
45
  ? 'the working directory'
36
46
  : undefined;
37
47
  if (what !== undefined) {
38
- throw new QuiverError('transport_error', `Refusing to build into "${outDir}": the build clears its output directory, and this one holds ${what} ("${out}"). Point --out at a directory the build owns.`);
48
+ throw new QuiverError('transport_error', `Refusing to build into "${outDir}": the build clears that directory and two staging siblings beside it, and this one holds ${what} ("${out}"). Point --out somewhere the build can own outright.`);
39
49
  }
40
50
  }
41
51
  /**
@@ -44,12 +54,16 @@ function assertSafeOutDir(path, sourceDir, outDir) {
44
54
  * Output layout. Every name but the pointer carries the SHA-256 of what it
45
55
  * names, which is what the loader checks on fetch:
46
56
  * outDir/
47
- * latest.json # stable pointer to the current manifest
57
+ * latest.json # the format, and a stable pointer to the manifest
48
58
  * manifest.<sha256:12>.json # hashed manifest
49
59
  * <name>@<version>.<sha256:12>.zip # one bundle per quill
50
60
  * store/
51
61
  * <sha256> # dehydrated font bytes (full hash, no ext)
52
62
  *
63
+ * A generation is assembled beside outDir and moved in whole, so a reader
64
+ * fetching mid-build reads the previous one rather than a torn tree, and a
65
+ * build that throws leaves the previous one serving.
66
+ *
53
67
  * Throws:
54
68
  * - `quiver_invalid` on source validation failures (propagated from scanner)
55
69
  * - `transport_error` on I/O failures, and on an outDir the build would have
@@ -58,106 +72,139 @@ function assertSafeOutDir(path, sourceDir, outDir) {
58
72
  export async function buildQuiver(sourceDir, outDir) {
59
73
  // Dynamic imports keep this module safe to type-import from browser contexts.
60
74
  const path = await import('node:path');
61
- const { join } = path;
62
- const { mkdir, rm, writeFile } = await import('node:fs/promises');
75
+ const { dirname, join, resolve } = path;
76
+ const { mkdir, rename, rm, writeFile } = await import('node:fs/promises');
77
+ const { existsSync } = await import('node:fs');
63
78
  const { createHash } = await import('node:crypto');
64
79
  const { scanSourceQuiver, readQuillTree } = await import('./source-loader.js');
65
- // 0. The first write is a recursive delete of outDir; refuse the paths where
66
- // that deletes the caller instead of a previous build.
80
+ // 0. Three trees get cleared here; refuse the paths where that deletes the
81
+ // caller instead of a previous build.
67
82
  assertSafeOutDir(path, sourceDir, outDir);
83
+ const out = resolve(outDir);
84
+ const { stage, prev } = stagesOf(out);
68
85
  // 1. Scan + validate source quiver (throws quiver_invalid on bad input).
69
86
  const { meta, catalog } = await scanSourceQuiver(sourceDir);
70
- // 2. Clear and recreate outDir + outDir/store/.
71
87
  try {
72
- await rm(outDir, { recursive: true, force: true });
73
- await mkdir(join(outDir, 'store'), { recursive: true });
88
+ await packGeneration();
74
89
  }
75
- catch (err) {
76
- throw new QuiverError('transport_error', `Failed to prepare output directory "${outDir}": ${err.message}`, { cause: err });
90
+ finally {
91
+ // A throw anywhere above leaves a partial generation staged, and outDir
92
+ // untouched. Neither tree outlives the call either way. Swept rather than
93
+ // checked: a sweep that threw would answer for the build, and what a caller
94
+ // needs to hear is why the build failed.
95
+ for (const at of [stage, prev])
96
+ await rm(at, { recursive: true, force: true }).catch(() => { });
77
97
  }
78
- // 3. Process each quill version.
79
- const manifestQuills = [];
80
- for (const [quillName, versions] of catalog) {
81
- for (const version of versions) {
82
- const quillDir = join(sourceDir, 'quills', quillName, version);
83
- // a. Read quill file tree.
84
- const tree = await readQuillTree(quillDir);
85
- // b. Partition fonts vs content.
86
- const fontEntries = [];
87
- const contentEntries = [];
88
- for (const [path, bytes] of tree) {
89
- if (FONT_EXT.test(path)) {
90
- fontEntries.push([path, bytes]);
98
+ /**
99
+ * Write a whole generation into `stage`, then move it to outDir.
100
+ *
101
+ * The move is two renames: a directory rename refuses a non-empty target, so the
102
+ * outgoing generation steps aside first. That is the window, and it is two syscalls
103
+ * wide against the seconds a build takes.
104
+ */
105
+ async function packGeneration() {
106
+ // 2. Clear and recreate the staged tree + its store/.
107
+ try {
108
+ await rm(stage, { recursive: true, force: true });
109
+ await mkdir(join(stage, 'store'), { recursive: true });
110
+ }
111
+ catch (err) {
112
+ throw new QuiverError('transport_error', `Failed to prepare output directory "${outDir}": ${err.message}`, { cause: err });
113
+ }
114
+ // 3. Process each quill version.
115
+ const manifestQuills = [];
116
+ for (const [quillName, versions] of catalog) {
117
+ for (const version of versions) {
118
+ const quillDir = join(sourceDir, 'quills', quillName, version);
119
+ // a. Read quill file tree.
120
+ const tree = await readQuillTree(quillDir);
121
+ // b. Partition fonts vs content.
122
+ const fontEntries = [];
123
+ const contentEntries = [];
124
+ for (const [path, bytes] of tree) {
125
+ if (FONT_EXT.test(path)) {
126
+ fontEntries.push([path, bytes]);
127
+ }
128
+ else {
129
+ contentEntries.push([path, bytes]);
130
+ }
91
131
  }
92
- else {
93
- contentEntries.push([path, bytes]);
132
+ // c. Dehydrate fonts into store/.
133
+ const fonts = {};
134
+ for (const [path, bytes] of fontEntries) {
135
+ // Full width: the store is keyed by hash, so two distinct fonts
136
+ // sharing a prefix would merge into one entry.
137
+ const hash = createHash('sha256').update(bytes).digest('hex');
138
+ try {
139
+ await writeFile(join(stage, 'store', hash), bytes);
140
+ }
141
+ catch (err) {
142
+ throw new QuiverError('transport_error', `Failed to write font store entry "${join(outDir, 'store', hash)}": ${err.message}`, { cause: err });
143
+ }
144
+ fonts[path] = hash;
94
145
  }
95
- }
96
- // c. Dehydrate fonts into store/.
97
- const fonts = {};
98
- for (const [path, bytes] of fontEntries) {
99
- // Full width: the store is keyed by hash, so two distinct fonts
100
- // sharing a prefix would merge into one entry.
101
- const hash = createHash('sha256').update(bytes).digest('hex');
102
- const storePath = join(outDir, 'store', hash);
146
+ // d. Zip content files (deterministic: sorted paths, fixed mtime).
147
+ const contentRecord = {};
148
+ for (const [path, bytes] of contentEntries) {
149
+ contentRecord[path] = bytes;
150
+ }
151
+ const zipBytes = packFiles(contentRecord);
152
+ // e–f. Compute bundle hash and name.
153
+ const bundleHash = createHash('sha256')
154
+ .update(zipBytes)
155
+ .digest('hex')
156
+ .slice(0, NAME_DIGEST_LENGTH);
157
+ const bundleName = `${quillName}@${version}.${bundleHash}.zip`;
158
+ // g. Write bundle zip.
103
159
  try {
104
- await writeFile(storePath, bytes);
160
+ await writeFile(join(stage, bundleName), zipBytes);
105
161
  }
106
162
  catch (err) {
107
- throw new QuiverError('transport_error', `Failed to write font store entry "${storePath}": ${err.message}`, { cause: err });
163
+ throw new QuiverError('transport_error', `Failed to write bundle "${join(outDir, bundleName)}": ${err.message}`, { cause: err });
108
164
  }
109
- fonts[path] = hash;
165
+ // h. Record manifest entry.
166
+ manifestQuills.push({ name: quillName, version, bundle: bundleName, fonts });
110
167
  }
111
- // d. Zip content files (deterministic: sorted paths, fixed mtime).
112
- const contentRecord = {};
113
- for (const [path, bytes] of contentEntries) {
114
- contentRecord[path] = bytes;
115
- }
116
- const zipBytes = packFiles(contentRecord);
117
- // e–f. Compute bundle hash and name.
118
- const bundleHash = createHash('sha256')
119
- .update(zipBytes)
120
- .digest('hex')
121
- .slice(0, NAME_DIGEST_LENGTH);
122
- const bundleName = `${quillName}@${version}.${bundleHash}.zip`;
123
- // g. Write bundle zip.
124
- const bundlePath = join(outDir, bundleName);
125
- try {
126
- await writeFile(bundlePath, zipBytes);
127
- }
128
- catch (err) {
129
- throw new QuiverError('transport_error', `Failed to write bundle "${bundlePath}": ${err.message}`, { cause: err });
130
- }
131
- // h. Record manifest entry.
132
- manifestQuills.push({ name: quillName, version, bundle: bundleName, fonts });
133
168
  }
134
- }
135
- // 4–8. Build and write hashed manifest.
136
- const manifest = {
137
- version: 1,
138
- name: meta.name,
139
- quills: manifestQuills
140
- };
141
- const manifestJson = JSON.stringify(manifest, null, 2);
142
- const manifestHash = createHash('sha256')
143
- .update(manifestJson)
144
- .digest('hex')
145
- .slice(0, NAME_DIGEST_LENGTH);
146
- const manifestFileName = `manifest.${manifestHash}.json`;
147
- const manifestPath = join(outDir, manifestFileName);
148
- try {
149
- await writeFile(manifestPath, manifestJson, 'utf-8');
150
- }
151
- catch (err) {
152
- throw new QuiverError('transport_error', `Failed to write manifest "${manifestPath}": ${err.message}`, { cause: err });
153
- }
154
- // 9–10. Write stable pointer latest.json.
155
- const pointer = { manifest: manifestFileName };
156
- const pointerPath = join(outDir, 'latest.json');
157
- try {
158
- await writeFile(pointerPath, JSON.stringify(pointer), 'utf-8');
159
- }
160
- catch (err) {
161
- throw new QuiverError('transport_error', `Failed to write pointer "${pointerPath}": ${err.message}`, { cause: err });
169
+ // 4–8. Build and write hashed manifest.
170
+ const manifest = {
171
+ version: 1,
172
+ name: meta.name,
173
+ quills: manifestQuills
174
+ };
175
+ const manifestJson = JSON.stringify(manifest, null, 2);
176
+ const manifestHash = createHash('sha256')
177
+ .update(manifestJson)
178
+ .digest('hex')
179
+ .slice(0, NAME_DIGEST_LENGTH);
180
+ const manifestFileName = `manifest.${manifestHash}.json`;
181
+ try {
182
+ await writeFile(join(stage, manifestFileName), manifestJson, 'utf-8');
183
+ }
184
+ catch (err) {
185
+ throw new QuiverError('transport_error', `Failed to write manifest "${join(outDir, manifestFileName)}": ${err.message}`, { cause: err });
186
+ }
187
+ // 9–10. Write stable pointer latest.json. The format is stamped here and read first,
188
+ // so a client older than the tree says so rather than misreading it.
189
+ const pointer = { format: POINTER_FORMAT, manifest: manifestFileName };
190
+ try {
191
+ await writeFile(join(stage, 'latest.json'), JSON.stringify(pointer), 'utf-8');
192
+ }
193
+ catch (err) {
194
+ throw new QuiverError('transport_error', `Failed to write pointer "${join(outDir, 'latest.json')}": ${err.message}`, { cause: err });
195
+ }
196
+ // 11. Move the generation in. `prev` is cleared first: a rename onto a
197
+ // non-empty directory fails, and the tree left by an interrupted run is
198
+ // one.
199
+ try {
200
+ await mkdir(dirname(out), { recursive: true });
201
+ await rm(prev, { recursive: true, force: true });
202
+ if (existsSync(out))
203
+ await rename(out, prev);
204
+ await rename(stage, out);
205
+ }
206
+ catch (err) {
207
+ throw new QuiverError('transport_error', `Failed to move the build into "${outDir}": ${err.message}`, { cause: err });
208
+ }
162
209
  }
163
210
  }
@@ -9,6 +9,7 @@ import { QuiverError } from './errors.js';
9
9
  import { unpackFiles } from './bundle.js';
10
10
  import { isCanonicalSemver, compareSemver } from './semver.js';
11
11
  import { NAME_DIGEST_LENGTH, sha256Hex } from './digest.js';
12
+ import { POINTER_FORMAT } from './format.js';
12
13
  import { createQuiver } from './quiver.js';
13
14
  /**
14
15
  * Fetch, then check the bytes against the digest their name carries. That
@@ -115,7 +116,18 @@ function parsePointer(raw) {
115
116
  throw new QuiverError('quiver_invalid', 'latest.json must be a JSON object');
116
117
  }
117
118
  const obj = parsed;
118
- assertNoUnknownKeys(obj, ['manifest'], 'latest.json');
119
+ // The format, read before anything is believed about the rest. An unknown key is NOT
120
+ // a failure here: this is the document a newer format announces itself on, and a
121
+ // reader rejecting what it does not recognise refuses the announcement too. Absent
122
+ // means a build from before the marker (`format.ts`).
123
+ const format = obj['format'] ?? POINTER_FORMAT;
124
+ if (typeof format !== 'number' || !Number.isInteger(format) || format < 1) {
125
+ throw new QuiverError('quiver_invalid', `latest.json: "format" must be a positive integer, got ${JSON.stringify(obj['format'])}`);
126
+ }
127
+ if (format > POINTER_FORMAT) {
128
+ throw new QuiverError('quiver_invalid', `This quiver is built in format ${format} and this loader reads ${POINTER_FORMAT}. ` +
129
+ `Upgrade @quillmark/quiver, or for a served client the client itself, which carries the copy that reads this.`);
130
+ }
119
131
  if (typeof obj['manifest'] !== 'string' || obj['manifest'].length === 0) {
120
132
  throw new QuiverError('quiver_invalid', 'latest.json must have a non-empty string "manifest" field');
121
133
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The built artifact's format, stamped into `latest.json` and refused to be read past
3
+ * (QUIVER §"The pointer" for why the pointer carries it and reads past keys it does not
4
+ * know).
5
+ *
6
+ * It moves when a reader assuming it would MISREAD a tree rather than fail on it: a
7
+ * renamed pointer field, a second pointer, a store keyed by something other than the
8
+ * full hash. A change confined to the manifest's own shape moves the manifest's
9
+ * `version` instead.
10
+ */
11
+ export declare const POINTER_FORMAT = 1;
package/dist/format.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The built artifact's format, stamped into `latest.json` and refused to be read past
3
+ * (QUIVER §"The pointer" for why the pointer carries it and reads past keys it does not
4
+ * know).
5
+ *
6
+ * It moves when a reader assuming it would MISREAD a tree rather than fail on it: a
7
+ * renamed pointer field, a second pointer, a store keyed by something other than the
8
+ * full hash. A change confined to the manifest's own shape moves the manifest's
9
+ * `version` instead.
10
+ */
11
+ export const POINTER_FORMAT = 1;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@quillmark/quiver",
3
- "version": "0.17.0",
4
- "description": "Quiver registry and build tooling for Quillmark",
3
+ "version": "0.19.0",
4
+ "description": "A quiver of Quillmark quills: load one, pack one, hand out quills.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -26,11 +26,13 @@
26
26
  "exports": {
27
27
  ".": {
28
28
  "types": "./dist/index.d.ts",
29
- "import": "./dist/index.js"
29
+ "import": "./dist/index.js",
30
+ "default": "./dist/index.js"
30
31
  },
31
32
  "./node": {
32
33
  "types": "./dist/node.d.ts",
33
- "import": "./dist/node.js"
34
+ "import": "./dist/node.js",
35
+ "default": "./dist/node.js"
34
36
  }
35
37
  },
36
38
  "sideEffects": false,
@@ -51,9 +53,6 @@
51
53
  "typescript": "^5.9.3",
52
54
  "vitest": "^4.0.18"
53
55
  },
54
- "bin": {
55
- "quiver": "./dist/bin/quiver.js"
56
- },
57
56
  "scripts": {
58
57
  "build": "tsc",
59
58
  "prepare": "npm run build",
@@ -1,16 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Quiver CLI — build and test a quiver collection. Two verbs, and they are the
4
- * whole of what a quiver author gets from here: `build` packs, `test` gates.
5
- * Looking at rendered output is studio's, an app rather than a verb.
6
- *
7
- * Commands:
8
- * quiver build [--out <dir>]
9
- * quiver test
10
- *
11
- * Engine discovery (for `test`):
12
- * 1. Named export `engine` from `quiver.config.js` at the collection root,
13
- * if the file exists.
14
- * 2. Auto-imported `@quillmark/wasm` from the collection's node_modules.
15
- */
16
- export {};
@@ -1,141 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Quiver CLI — build and test a quiver collection. Two verbs, and they are the
4
- * whole of what a quiver author gets from here: `build` packs, `test` gates.
5
- * Looking at rendered output is studio's, an app rather than a verb.
6
- *
7
- * Commands:
8
- * quiver build [--out <dir>]
9
- * quiver test
10
- *
11
- * Engine discovery (for `test`):
12
- * 1. Named export `engine` from `quiver.config.js` at the collection root,
13
- * if the file exists.
14
- * 2. Auto-imported `@quillmark/wasm` from the collection's node_modules.
15
- */
16
- import { createRequire } from 'node:module';
17
- import { join } from 'node:path';
18
- import { pathToFileURL } from 'node:url';
19
- import { build as buildQuiver, fromDir } from '../node.js';
20
- // ---------------------------------------------------------------------------
21
- // Arg parsing helpers
22
- // ---------------------------------------------------------------------------
23
- const argv = process.argv.slice(2);
24
- const command = argv[0];
25
- /** One flag with a value. `build --out` is the only one either verb takes. */
26
- function flag(name) {
27
- const i = argv.indexOf(name);
28
- return i !== -1 && i + 1 < argv.length ? argv[i + 1] : undefined;
29
- }
30
- // ---------------------------------------------------------------------------
31
- // Engine discovery
32
- // ---------------------------------------------------------------------------
33
- async function loadEngine(cwd) {
34
- // Resolve @quillmark/wasm from the collection's node_modules (best-effort).
35
- // Its `Engine` renders every quill (cloning the core Quill/Document into the
36
- // backend itself). A quiver.config.js may export a custom `engine`.
37
- let wasm;
38
- try {
39
- const req = createRequire(pathToFileURL(join(cwd, 'package.json')).href);
40
- const wasmPath = req.resolve('@quillmark/wasm');
41
- wasm = (await import(pathToFileURL(wasmPath).href));
42
- // Instantiate the core. Every `@quillmark/wasm` export throws
43
- // `runtime::not_initialized` until `init()` resolves, and `new Engine()` is
44
- // lazy, so without this the throw arrives at the first render and reads as a
45
- // failing quill rather than an uninitialized runtime.
46
- await wasm.init();
47
- }
48
- catch {
49
- // Not installed — a quiver.config.js may still provide an engine.
50
- }
51
- // 1. Named `engine` export from quiver.config.js, if present.
52
- try {
53
- const cfg = await import(pathToFileURL(join(cwd, 'quiver.config.js')).href);
54
- if (cfg.engine != null) {
55
- return cfg.engine;
56
- }
57
- }
58
- catch {
59
- // File absent or incomplete — fall through to auto-discovery.
60
- }
61
- // 2. Auto-discover from @quillmark/wasm.
62
- if (wasm == null) {
63
- throw new Error("Cannot find @quillmark/wasm in this collection's node_modules.\n" +
64
- ' Install it: npm install @quillmark/wasm\n' +
65
- ' Or export { engine } from quiver.config.js for a custom engine.');
66
- }
67
- return new wasm.Engine();
68
- }
69
- // ---------------------------------------------------------------------------
70
- // Commands
71
- // ---------------------------------------------------------------------------
72
- async function build() {
73
- const cwd = process.cwd();
74
- const outDir = flag('--out') ?? 'dist';
75
- console.log(`quiver build: ${cwd} → ${outDir}`);
76
- await buildQuiver(cwd, outDir);
77
- console.log('done.');
78
- }
79
- async function test() {
80
- const cwd = process.cwd();
81
- const engine = await loadEngine(cwd);
82
- const quiver = await fromDir(cwd);
83
- const names = quiver.quillNames();
84
- if (names.length === 0) {
85
- console.error('error: quiver has no quills');
86
- process.exit(1);
87
- }
88
- let pass = 0;
89
- let fail = 0;
90
- for (const name of names) {
91
- for (const version of quiver.versionsOf(name)) {
92
- const ref = `${name}@${version}`;
93
- try {
94
- const quill = await quiver.getQuill(ref);
95
- const doc = quill.seedDocument();
96
- let result;
97
- try {
98
- result = await engine.render(quill, doc);
99
- }
100
- finally {
101
- doc.free();
102
- }
103
- if (!Array.isArray(result.artifacts) || result.artifacts.length === 0) {
104
- throw new Error('example render produced no artifacts');
105
- }
106
- console.log(`pass ${ref}`);
107
- pass++;
108
- }
109
- catch (err) {
110
- console.error(`FAIL ${ref} — ${err.message}`);
111
- fail++;
112
- }
113
- }
114
- }
115
- const total = pass + fail;
116
- console.log(`\n${pass}/${total} passed`);
117
- if (fail > 0)
118
- process.exit(1);
119
- }
120
- // ---------------------------------------------------------------------------
121
- // Dispatch
122
- // ---------------------------------------------------------------------------
123
- function usage() {
124
- console.error(['Usage:', ' quiver build [--out <dir>]', ' quiver test'].join('\n'));
125
- }
126
- function die(err) {
127
- const msg = err instanceof Error ? err.message : String(err);
128
- console.error(`error: ${msg}`);
129
- process.exit(1);
130
- }
131
- switch (command) {
132
- case 'build':
133
- build().catch(die);
134
- break;
135
- case 'test':
136
- test().catch(die);
137
- break;
138
- default:
139
- usage();
140
- process.exit(1);
141
- }