@quillmark/quiver 0.23.0 → 0.24.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 +11 -1
- package/dist/build.d.ts +16 -3
- package/dist/build.js +20 -4
- package/dist/built-loader.js +7 -1
- package/dist/bundle.d.ts +2 -0
- package/dist/bundle.js +54 -2
- package/dist/digest.d.ts +9 -5
- package/dist/digest.js +9 -5
- package/dist/node.d.ts +6 -1
- package/dist/node.js +5 -2
- package/dist/quiver-yaml.js +2 -2
- package/dist/ref.d.ts +6 -0
- package/dist/ref.js +8 -0
- package/dist/semver.d.ts +4 -0
- package/dist/semver.js +6 -0
- package/dist/source-loader.d.ts +5 -3
- package/dist/source-loader.js +30 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -68,6 +68,8 @@ await build('./node_modules/@org/my-quiver', './public/quivers/my-quiver');
|
|
|
68
68
|
|
|
69
69
|
`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.
|
|
70
70
|
|
|
71
|
+
Quills below `0.1.0` are drafts and are left out: they stay in the source layout, which `fromDir` reads whole, and reach the artifact only under `build(src, out, { drafts: true })`. A quill with no version above the floor is absent from the built catalog entirely.
|
|
72
|
+
|
|
71
73
|
```ts
|
|
72
74
|
// browser runtime
|
|
73
75
|
import { Engine, init } from '@quillmark/wasm';
|
|
@@ -82,7 +84,7 @@ const quill = await quiver.getQuill(doc.quillRef);
|
|
|
82
84
|
const result = await engine.render(quill, doc, { format: 'pdf' });
|
|
83
85
|
```
|
|
84
86
|
|
|
85
|
-
A CDN URL works the same way, for consumers who cannot run a Node build step of their own.
|
|
87
|
+
A CDN URL works the same way, for consumers who cannot run a Node build step of their own — a collection you publish, on a host you pick, and pinned like the dependency [it is](#what-a-quiver-is-trusted-to-be).
|
|
86
88
|
|
|
87
89
|
## Server-side runtime (Node, packed artifact on disk)
|
|
88
90
|
|
|
@@ -124,6 +126,14 @@ The seed answers first and the URL serves what it does not carry. Seeding `lates
|
|
|
124
126
|
|
|
125
127
|
`Quiver.fromBuiltUrl(url)` first fetches `<url>/latest.json`, a stable-named pointer to the current manifest. Everything behind that pointer is content-addressed and checked against the digest in its name; the pointer itself is not, so a cache layer can serve a stale one and silently pin 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), which closes the browser-cache layer. A stale CDN edge is answered by that host's cache headers.
|
|
126
128
|
|
|
129
|
+
The check itself needs `crypto.subtle`, which a browser exposes only in a secure context. An `https` page, `http://localhost` and Node have one; a page served over plain `http` to anything else — a dev host on a LAN address, a staging box without a certificate — has none, and there every fetch passes through unchecked.
|
|
130
|
+
|
|
131
|
+
## What a quiver is trusted to be
|
|
132
|
+
|
|
133
|
+
**A quill is a template the backend executes, so loading a quiver runs its author's code.** Point one at a source you would take a dependency from — an npm package or a git tag, pinned like any other — because nothing here sandboxes a quill, and a collection assembled from anywhere else is a decision to make on purpose.
|
|
134
|
+
|
|
135
|
+
Content addressing is an integrity check and not a provenance one: the digest in each name catches a corrupted object, a partial sync and a name reused across releases, and says nothing about who packed the bytes. Where the primitive behind it is absent the page is no secure context either, so the pointer, the manifest, the digests and the client itself all arrived over the same unauthenticated channel — which is the whole of why a built quiver is served over `https`.
|
|
136
|
+
|
|
127
137
|
## Error handling
|
|
128
138
|
|
|
129
139
|
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.
|
package/dist/build.d.ts
CHANGED
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
* a module reaching this one for types alone does not pull `node:fs` or
|
|
6
6
|
* `node:crypto` into a browser bundle.
|
|
7
7
|
*/
|
|
8
|
+
/** Options for {@link buildQuiver}. */
|
|
9
|
+
export interface BuildOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Pack versions below `MIN_PUBLISHED_VERSION` too. Off by default: the
|
|
12
|
+
* artifact is a deployment, and the draft space is not part of one. An
|
|
13
|
+
* author's own viewer turns it on, which is the only caller that should.
|
|
14
|
+
*/
|
|
15
|
+
drafts?: boolean;
|
|
16
|
+
}
|
|
8
17
|
/**
|
|
9
18
|
* Reads a Source Quiver, validates it, and writes the build output to outDir.
|
|
10
19
|
*
|
|
@@ -12,8 +21,8 @@
|
|
|
12
21
|
* names, which is what the loader checks on fetch:
|
|
13
22
|
* outDir/
|
|
14
23
|
* latest.json # the format, and a stable pointer to the manifest
|
|
15
|
-
* manifest.<sha256:
|
|
16
|
-
* <name>@<version>.<sha256:
|
|
24
|
+
* manifest.<sha256:32>.json # hashed manifest
|
|
25
|
+
* <name>@<version>.<sha256:32>.zip # one bundle per quill
|
|
17
26
|
* store/
|
|
18
27
|
* <sha256> # dehydrated font bytes (full hash, no ext)
|
|
19
28
|
*
|
|
@@ -21,9 +30,13 @@
|
|
|
21
30
|
* fetching mid-build reads the previous one rather than a torn tree, and a
|
|
22
31
|
* build that throws leaves the previous one serving.
|
|
23
32
|
*
|
|
33
|
+
* Versions below `MIN_PUBLISHED_VERSION` are drafts and are left out unless
|
|
34
|
+
* `options.drafts` asks for them; a quill with nothing above the floor is
|
|
35
|
+
* absent from the manifest entirely.
|
|
36
|
+
*
|
|
24
37
|
* Throws:
|
|
25
38
|
* - `quiver_invalid` on source validation failures (propagated from scanner)
|
|
26
39
|
* - `transport_error` on I/O failures, and on an outDir the build would have
|
|
27
40
|
* to delete something it does not own to write (see `assertSafeOutDir`)
|
|
28
41
|
*/
|
|
29
|
-
export declare function buildQuiver(sourceDir: string, outDir: string): Promise<void>;
|
|
42
|
+
export declare function buildQuiver(sourceDir: string, outDir: string, options?: BuildOptions): Promise<void>;
|
package/dist/build.js
CHANGED
|
@@ -9,6 +9,7 @@ import { QuiverError } from './errors.js';
|
|
|
9
9
|
import { packFiles } from './bundle.js';
|
|
10
10
|
import { NAME_DIGEST_LENGTH } from './digest.js';
|
|
11
11
|
import { MANIFEST_VERSION, POINTER_FORMAT } from './format.js';
|
|
12
|
+
import { isDraft } from './semver.js';
|
|
12
13
|
/** Font file extensions recognised by the builder (case-insensitive). */
|
|
13
14
|
const FONT_EXT = /\.(ttf|otf|woff|woff2)$/i;
|
|
14
15
|
/** The two trees a build owns beside its output: where a generation is assembled, and
|
|
@@ -55,8 +56,8 @@ function assertSafeOutDir(path, sourceDir, outDir) {
|
|
|
55
56
|
* names, which is what the loader checks on fetch:
|
|
56
57
|
* outDir/
|
|
57
58
|
* latest.json # the format, and a stable pointer to the manifest
|
|
58
|
-
* manifest.<sha256:
|
|
59
|
-
* <name>@<version>.<sha256:
|
|
59
|
+
* manifest.<sha256:32>.json # hashed manifest
|
|
60
|
+
* <name>@<version>.<sha256:32>.zip # one bundle per quill
|
|
60
61
|
* store/
|
|
61
62
|
* <sha256> # dehydrated font bytes (full hash, no ext)
|
|
62
63
|
*
|
|
@@ -64,12 +65,16 @@ function assertSafeOutDir(path, sourceDir, outDir) {
|
|
|
64
65
|
* fetching mid-build reads the previous one rather than a torn tree, and a
|
|
65
66
|
* build that throws leaves the previous one serving.
|
|
66
67
|
*
|
|
68
|
+
* Versions below `MIN_PUBLISHED_VERSION` are drafts and are left out unless
|
|
69
|
+
* `options.drafts` asks for them; a quill with nothing above the floor is
|
|
70
|
+
* absent from the manifest entirely.
|
|
71
|
+
*
|
|
67
72
|
* Throws:
|
|
68
73
|
* - `quiver_invalid` on source validation failures (propagated from scanner)
|
|
69
74
|
* - `transport_error` on I/O failures, and on an outDir the build would have
|
|
70
75
|
* to delete something it does not own to write (see `assertSafeOutDir`)
|
|
71
76
|
*/
|
|
72
|
-
export async function buildQuiver(sourceDir, outDir) {
|
|
77
|
+
export async function buildQuiver(sourceDir, outDir, options = {}) {
|
|
73
78
|
// Dynamic imports keep this module safe to type-import from browser contexts.
|
|
74
79
|
const path = await import('node:path');
|
|
75
80
|
const { dirname, join, resolve } = path;
|
|
@@ -110,6 +115,8 @@ export async function buildQuiver(sourceDir, outDir) {
|
|
|
110
115
|
const manifestQuills = [];
|
|
111
116
|
for (const [quillName, versions] of catalog) {
|
|
112
117
|
for (const version of versions) {
|
|
118
|
+
if (!options.drafts && isDraft(version))
|
|
119
|
+
continue;
|
|
113
120
|
const quillDir = join(sourceDir, 'quills', quillName, version);
|
|
114
121
|
const tree = await readQuillTree(quillDir);
|
|
115
122
|
const fontEntries = [];
|
|
@@ -139,7 +146,16 @@ export async function buildQuiver(sourceDir, outDir) {
|
|
|
139
146
|
for (const [path, bytes] of contentEntries) {
|
|
140
147
|
contentRecord[path] = bytes;
|
|
141
148
|
}
|
|
142
|
-
|
|
149
|
+
// The budget `bundle.ts` carries is spent here as well as at the read, so
|
|
150
|
+
// a quill no loader would take is refused by the build that packs it,
|
|
151
|
+
// where the author it names can still do something about it.
|
|
152
|
+
let zipBytes;
|
|
153
|
+
try {
|
|
154
|
+
zipBytes = packFiles(contentRecord);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
throw new QuiverError('quiver_invalid', `Quill "${quillName}@${version}": ${err.message}`, { quiverName: meta.name, version, cause: err });
|
|
158
|
+
}
|
|
143
159
|
const bundleHash = createHash('sha256')
|
|
144
160
|
.update(zipBytes)
|
|
145
161
|
.digest('hex')
|
package/dist/built-loader.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { QuiverError } from './errors.js';
|
|
9
9
|
import { unpackFiles } from './bundle.js';
|
|
10
|
+
import { isQuillName } from './ref.js';
|
|
10
11
|
import { isCanonicalSemver, compareSemver } from './semver.js';
|
|
11
12
|
import { NAME_DIGEST_LENGTH, sha256Hex } from './digest.js';
|
|
12
13
|
import { MANIFEST_VERSION, POINTER_FORMAT } from './format.js';
|
|
@@ -37,7 +38,9 @@ async function fetchVerified(transport, path, expected) {
|
|
|
37
38
|
// chars for bundles and manifests and the full 64 for store entries.
|
|
38
39
|
const DIGEST = `[0-9a-f]{${NAME_DIGEST_LENGTH},}`;
|
|
39
40
|
const MANIFEST_FILENAME_RE = new RegExp(`^manifest\\.(${DIGEST})\\.json$`);
|
|
40
|
-
|
|
41
|
+
// The name span is the ref charset, not a filename charset: `build` spells a bundle
|
|
42
|
+
// off the catalog row, and a row is a name a ref can spell.
|
|
43
|
+
const BUNDLE_FILENAME_RE = new RegExp(`^[A-Za-z0-9_-]+@[0-9]+\\.[0-9]+\\.[0-9]+\\.(${DIGEST})\\.zip$`);
|
|
41
44
|
const FONT_HASH_RE = /^[0-9a-f]{64}$/;
|
|
42
45
|
/** The digest a content-addressed filename carries. Throws `quiver_invalid`. */
|
|
43
46
|
function digestOfName(re, name, what) {
|
|
@@ -171,6 +174,9 @@ function parseManifest(raw) {
|
|
|
171
174
|
if (typeof e['name'] !== 'string' || e['name'].length === 0) {
|
|
172
175
|
throw new QuiverError('quiver_invalid', `manifest.quills[${i}].name must be a non-empty string`);
|
|
173
176
|
}
|
|
177
|
+
if (!isQuillName(e['name'])) {
|
|
178
|
+
throw new QuiverError('quiver_invalid', `manifest.quills[${i}].name "${e['name']}" is not a name a ref can spell — only [A-Za-z0-9_-] are allowed`);
|
|
179
|
+
}
|
|
174
180
|
if (typeof e['version'] !== 'string' || !isCanonicalSemver(e['version'])) {
|
|
175
181
|
throw new QuiverError('quiver_invalid', `manifest.quills[${i}].version must be canonical semver (x.y.z), got "${String(e['version'])}"`);
|
|
176
182
|
}
|
package/dist/bundle.d.ts
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Pack a flat file map into a deterministic zip.
|
|
6
6
|
* Keys are sorted before zipping so insertion order doesn't affect output.
|
|
7
|
+
* Throws `quiver_invalid` where the budget is spent.
|
|
7
8
|
*/
|
|
8
9
|
export declare function packFiles(files: Record<string, Uint8Array>): Uint8Array;
|
|
9
10
|
/**
|
|
10
11
|
* Unpack a zip into a flat file map.
|
|
11
12
|
* Returns { path: Uint8Array } for every file entry in the archive.
|
|
13
|
+
* Throws `quiver_invalid` where the budget is spent, before any entry inflates.
|
|
12
14
|
*/
|
|
13
15
|
export declare function unpackFiles(data: Uint8Array): Record<string, Uint8Array>;
|
package/dist/bundle.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Zip utilities — browser-safe (uses fflate only).
|
|
3
3
|
*/
|
|
4
4
|
import { zipSync, unzipSync } from 'fflate';
|
|
5
|
+
import { QuiverError } from './errors.js';
|
|
5
6
|
/**
|
|
6
7
|
* Fixed epoch mtime for deterministic zip output.
|
|
7
8
|
*
|
|
@@ -13,24 +14,75 @@ import { zipSync, unzipSync } from 'fflate';
|
|
|
13
14
|
* is always identical.
|
|
14
15
|
*/
|
|
15
16
|
const ZIP_EPOCH = new Date(1980, 0, 1, 0, 0, 0, 0);
|
|
17
|
+
/**
|
|
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 to the store and the manifest names them,
|
|
27
|
+
* leaving a bundle its markdown, its Typst, and what those inline.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
30
|
+
const MAX_ENTRY_BYTES = 16 * 1024 * 1024;
|
|
31
|
+
const MAX_BUNDLE_ENTRIES = 2048;
|
|
32
|
+
/**
|
|
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 the corrupt bundle the digest
|
|
39
|
+
* already answers.
|
|
40
|
+
*/
|
|
41
|
+
function bundleBudget() {
|
|
42
|
+
let total = 0;
|
|
43
|
+
let count = 0;
|
|
44
|
+
return (name, size) => {
|
|
45
|
+
if (++count > MAX_BUNDLE_ENTRIES) {
|
|
46
|
+
throw new QuiverError('quiver_invalid', `Bundle holds over ${MAX_BUNDLE_ENTRIES} files, the most one may hold`);
|
|
47
|
+
}
|
|
48
|
+
if (size > MAX_ENTRY_BYTES) {
|
|
49
|
+
throw new QuiverError('quiver_invalid', `Bundled file "${name}" is ${size} bytes, over the ${MAX_ENTRY_BYTES} one file may be`);
|
|
50
|
+
}
|
|
51
|
+
total += size;
|
|
52
|
+
if (total > MAX_BUNDLE_BYTES) {
|
|
53
|
+
throw new QuiverError('quiver_invalid', `Bundle unpacks to over ${MAX_BUNDLE_BYTES} bytes, the most one may hold`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
16
57
|
/**
|
|
17
58
|
* Pack a flat file map into a deterministic zip.
|
|
18
59
|
* Keys are sorted before zipping so insertion order doesn't affect output.
|
|
60
|
+
* Throws `quiver_invalid` where the budget is spent.
|
|
19
61
|
*/
|
|
20
62
|
export function packFiles(files) {
|
|
63
|
+
const spend = bundleBudget();
|
|
21
64
|
const sorted = Object.keys(files).sort();
|
|
22
65
|
const input = {};
|
|
23
66
|
for (const key of sorted) {
|
|
24
|
-
|
|
67
|
+
const bytes = files[key];
|
|
68
|
+
spend(key, bytes.length);
|
|
69
|
+
input[key] = [bytes, { mtime: ZIP_EPOCH }];
|
|
25
70
|
}
|
|
26
71
|
return zipSync(input, { level: 6 });
|
|
27
72
|
}
|
|
28
73
|
/**
|
|
29
74
|
* Unpack a zip into a flat file map.
|
|
30
75
|
* Returns { path: Uint8Array } for every file entry in the archive.
|
|
76
|
+
* Throws `quiver_invalid` where the budget is spent, before any entry inflates.
|
|
31
77
|
*/
|
|
32
78
|
export function unpackFiles(data) {
|
|
33
|
-
const
|
|
79
|
+
const spend = bundleBudget();
|
|
80
|
+
const raw = unzipSync(data, {
|
|
81
|
+
filter: ({ name, originalSize }) => {
|
|
82
|
+
spend(name, originalSize);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
34
86
|
const result = {};
|
|
35
87
|
for (const [key, value] of Object.entries(raw)) {
|
|
36
88
|
result[key] = value;
|
package/dist/digest.d.ts
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
* have one.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
* Digest width in the names `build` writes, in hex chars:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Digest width in the names `build` writes, in hex chars: 128 bits of SHA-256.
|
|
13
|
+
*
|
|
14
|
+
* The width answers a chosen prefix, not a collision. Where the manifest is trusted
|
|
15
|
+
* and the bundles are not — the seeded deployment, where the two come from different
|
|
16
|
+
* places by design — this is the only thing between a served zip and the quill it
|
|
17
|
+
* claims to be, and a quill is a template the backend executes. Grinding padding until
|
|
18
|
+
* SHA-256 lands on a chosen prefix costs hours of one GPU at 48 bits and is out of
|
|
19
|
+
* reach at 128.
|
|
16
20
|
*/
|
|
17
|
-
export declare const NAME_DIGEST_LENGTH =
|
|
21
|
+
export declare const NAME_DIGEST_LENGTH = 32;
|
|
18
22
|
/** SHA-256 over `bytes` as lowercase hex, or undefined where no digest exists. */
|
|
19
23
|
export declare function sha256Hex(bytes: Uint8Array): Promise<string | undefined>;
|
package/dist/digest.js
CHANGED
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
* have one.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
* Digest width in the names `build` writes, in hex chars:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Digest width in the names `build` writes, in hex chars: 128 bits of SHA-256.
|
|
13
|
+
*
|
|
14
|
+
* The width answers a chosen prefix, not a collision. Where the manifest is trusted
|
|
15
|
+
* and the bundles are not — the seeded deployment, where the two come from different
|
|
16
|
+
* places by design — this is the only thing between a served zip and the quill it
|
|
17
|
+
* claims to be, and a quill is a template the backend executes. Grinding padding until
|
|
18
|
+
* SHA-256 lands on a chosen prefix costs hours of one GPU at 48 bits and is out of
|
|
19
|
+
* reach at 128.
|
|
16
20
|
*/
|
|
17
|
-
export const NAME_DIGEST_LENGTH =
|
|
21
|
+
export const NAME_DIGEST_LENGTH = 32;
|
|
18
22
|
/** SHA-256 over `bytes` as lowercase hex, or undefined where no digest exists. */
|
|
19
23
|
export async function sha256Hex(bytes) {
|
|
20
24
|
const subtle = globalThis.crypto?.subtle;
|
package/dist/node.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* makes no static or dynamic reference to it.
|
|
10
10
|
*/
|
|
11
11
|
import { Quiver } from './quiver.js';
|
|
12
|
+
import type { BuildOptions } from './build.js';
|
|
12
13
|
/**
|
|
13
14
|
* Reads a Source Quiver from a local directory containing `Quiver.yaml` and
|
|
14
15
|
* `quills/<name>/<version>/Quill.yaml` entries.
|
|
@@ -42,10 +43,14 @@ export declare function fromBuiltDir(dirPath: string): Promise<Quiver>;
|
|
|
42
43
|
* Reads the Source Quiver at sourceDir, validates it, and writes the runtime
|
|
43
44
|
* build artifact to outDir, which the build clears first and therefore owns.
|
|
44
45
|
*
|
|
46
|
+
* Quills below `0.1.0` are drafts: they stay in the source layout, which
|
|
47
|
+
* `fromDir` reads whole, and reach the artifact only under `{ drafts: true }`.
|
|
48
|
+
*
|
|
45
49
|
* Throws `quiver_invalid` on source validation failures, `transport_error` on
|
|
46
50
|
* I/O failures and on an outDir holding the source quiver or the cwd.
|
|
47
51
|
*/
|
|
48
|
-
export declare function build(sourceDir: string, outDir: string): Promise<void>;
|
|
52
|
+
export declare function build(sourceDir: string, outDir: string, options?: BuildOptions): Promise<void>;
|
|
49
53
|
export { Quiver } from './quiver.js';
|
|
50
54
|
export { QuiverError } from './errors.js';
|
|
51
55
|
export type { QuiverErrorCode } from './errors.js';
|
|
56
|
+
export type { BuildOptions } from './build.js';
|
package/dist/node.js
CHANGED
|
@@ -55,11 +55,14 @@ export async function fromBuiltDir(dirPath) {
|
|
|
55
55
|
* Reads the Source Quiver at sourceDir, validates it, and writes the runtime
|
|
56
56
|
* build artifact to outDir, which the build clears first and therefore owns.
|
|
57
57
|
*
|
|
58
|
+
* Quills below `0.1.0` are drafts: they stay in the source layout, which
|
|
59
|
+
* `fromDir` reads whole, and reach the artifact only under `{ drafts: true }`.
|
|
60
|
+
*
|
|
58
61
|
* Throws `quiver_invalid` on source validation failures, `transport_error` on
|
|
59
62
|
* I/O failures and on an outDir holding the source quiver or the cwd.
|
|
60
63
|
*/
|
|
61
|
-
export async function build(sourceDir, outDir) {
|
|
62
|
-
return buildQuiver(sourceDir, outDir);
|
|
64
|
+
export async function build(sourceDir, outDir, options) {
|
|
65
|
+
return buildQuiver(sourceDir, outDir, options);
|
|
63
66
|
}
|
|
64
67
|
// The rest of the public surface, so a Node consumer needs one import.
|
|
65
68
|
export { Quiver } from './quiver.js';
|
package/dist/quiver-yaml.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { parse as parseYaml } from 'yaml';
|
|
7
7
|
import { QuiverError } from './errors.js';
|
|
8
|
-
|
|
8
|
+
import { isQuillName } from './ref.js';
|
|
9
9
|
const KNOWN_FIELDS = new Set(['name', 'description']);
|
|
10
10
|
/**
|
|
11
11
|
* Parses and validates Quiver.yaml contents.
|
|
@@ -42,7 +42,7 @@ export function parseQuiverYaml(raw) {
|
|
|
42
42
|
throw new QuiverError('quiver_invalid', `Quiver.yaml: "name" must be a string, got ${typeof doc['name']}`);
|
|
43
43
|
}
|
|
44
44
|
const name = doc['name'];
|
|
45
|
-
if (!
|
|
45
|
+
if (!isQuillName(name)) {
|
|
46
46
|
throw new QuiverError('quiver_invalid', `Quiver.yaml: "name" value "${name}" contains invalid characters — only [A-Za-z0-9_-] are allowed`);
|
|
47
47
|
}
|
|
48
48
|
if ('description' in doc && doc['description'] !== undefined) {
|
package/dist/ref.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export interface ParsedQuillRef {
|
|
|
6
6
|
/** Selector part count: 1 = `x`, 2 = `x.y`, 3 = `x.y.z` (exact). */
|
|
7
7
|
selectorDepth?: 1 | 2 | 3;
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Whether `name` is a name a ref can spell. A quiver's own name and every catalog
|
|
11
|
+
* row are held to it: a row seated outside it is one `quillNames` lists and
|
|
12
|
+
* `getQuill` refuses.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isQuillName(name: string): boolean;
|
|
9
15
|
/**
|
|
10
16
|
* Throws QuiverError('invalid_ref') on malformed input. The name is
|
|
11
17
|
* `[A-Za-z0-9_-]+`; the selector is `x`, `x.y` or `x.y.z`, digits only, with no
|
package/dist/ref.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { QuiverError } from './errors.js';
|
|
2
2
|
const NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
3
3
|
const SELECTOR_RE = /^\d+(\.\d+){0,2}$/;
|
|
4
|
+
/**
|
|
5
|
+
* Whether `name` is a name a ref can spell. A quiver's own name and every catalog
|
|
6
|
+
* row are held to it: a row seated outside it is one `quillNames` lists and
|
|
7
|
+
* `getQuill` refuses.
|
|
8
|
+
*/
|
|
9
|
+
export function isQuillName(name) {
|
|
10
|
+
return NAME_RE.test(name);
|
|
11
|
+
}
|
|
4
12
|
/**
|
|
5
13
|
* Throws QuiverError('invalid_ref') on malformed input. The name is
|
|
6
14
|
* `[A-Za-z0-9_-]+`; the selector is `x`, `x.y` or `x.y.z`, digits only, with no
|
package/dist/semver.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** The lowest version a build packs. Below it is the draft space. */
|
|
2
|
+
export declare const MIN_PUBLISHED_VERSION = "0.1.0";
|
|
1
3
|
/** Returns true for exactly `x.y.z` with non-negative integer parts. */
|
|
2
4
|
export declare function isCanonicalSemver(version: string): boolean;
|
|
3
5
|
/**
|
|
@@ -8,5 +10,7 @@ export declare function isCanonicalSemver(version: string): boolean;
|
|
|
8
10
|
export declare function matchesSemverSelector(version: string, selector: string): boolean;
|
|
9
11
|
/** Compares two canonical semver strings. Returns <0, 0, or >0. */
|
|
10
12
|
export declare function compareSemver(a: string, b: string): number;
|
|
13
|
+
/** Returns true for a canonical version below `MIN_PUBLISHED_VERSION`. */
|
|
14
|
+
export declare function isDraft(version: string): boolean;
|
|
11
15
|
/** Returns the highest version string, or null if empty. */
|
|
12
16
|
export declare function chooseHighestVersion(versions: string[]): string | null;
|
package/dist/semver.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** The lowest version a build packs. Below it is the draft space. */
|
|
2
|
+
export const MIN_PUBLISHED_VERSION = '0.1.0';
|
|
1
3
|
/** Returns true for exactly `x.y.z` with non-negative integer parts. */
|
|
2
4
|
export function isCanonicalSemver(version) {
|
|
3
5
|
return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version);
|
|
@@ -33,6 +35,10 @@ export function compareSemver(a, b) {
|
|
|
33
35
|
}
|
|
34
36
|
return 0;
|
|
35
37
|
}
|
|
38
|
+
/** Returns true for a canonical version below `MIN_PUBLISHED_VERSION`. */
|
|
39
|
+
export function isDraft(version) {
|
|
40
|
+
return compareSemver(version, MIN_PUBLISHED_VERSION) < 0;
|
|
41
|
+
}
|
|
36
42
|
/** Returns the highest version string, or null if empty. */
|
|
37
43
|
export function chooseHighestVersion(versions) {
|
|
38
44
|
if (versions.length === 0)
|
package/dist/source-loader.d.ts
CHANGED
|
@@ -11,8 +11,10 @@ import type { QuiverLoader } from './quiver.js';
|
|
|
11
11
|
* to build a catalog of quill names → sorted versions (descending).
|
|
12
12
|
*
|
|
13
13
|
* Throws:
|
|
14
|
-
* - `quiver_invalid` if Quiver.yaml is missing/invalid, a
|
|
15
|
-
*
|
|
14
|
+
* - `quiver_invalid` if Quiver.yaml is missing/invalid, a quill or version directory
|
|
15
|
+
* is a symlink, a quill dir holding versions is named outside the ref charset, a
|
|
16
|
+
* version dir name is non-canonical, or a version dir is missing its Quill.yaml
|
|
17
|
+
* sentinel.
|
|
16
18
|
* - `transport_error` for I/O failures (permissions, etc.).
|
|
17
19
|
*
|
|
18
20
|
* Missing `quills/` directory is not an error — the quiver is valid but empty.
|
|
@@ -25,7 +27,7 @@ export declare function scanSourceQuiver(rootDir: string): Promise<{
|
|
|
25
27
|
* Recursively reads all files under a quill version directory into a Map.
|
|
26
28
|
*
|
|
27
29
|
* Keys are relative POSIX paths (forward slashes, no leading slash).
|
|
28
|
-
* Throws `transport_error` on I/O failure.
|
|
30
|
+
* Throws `quiver_invalid` on a symlink, `transport_error` on I/O failure.
|
|
29
31
|
*/
|
|
30
32
|
export declare function readQuillTree(quillDir: string): Promise<Map<string, Uint8Array>>;
|
|
31
33
|
/**
|
package/dist/source-loader.js
CHANGED
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
* Internal filesystem scanner for the Source Quiver layout. Statically imports
|
|
3
3
|
* `node:fs`, so it is reachable from the Node entry alone.
|
|
4
4
|
*/
|
|
5
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
5
|
+
import { readdir, readFile, lstat, stat } from 'node:fs/promises';
|
|
6
6
|
import { join, relative, sep } from 'node:path';
|
|
7
7
|
import { QuiverError } from './errors.js';
|
|
8
8
|
import { parseQuiverYaml } from './quiver-yaml.js';
|
|
9
|
+
import { isQuillName } from './ref.js';
|
|
9
10
|
import { isCanonicalSemver, compareSemver } from './semver.js';
|
|
11
|
+
/**
|
|
12
|
+
* Refuse a symlink. Every rung that opens a tree or reads a file `lstat`s and asks,
|
|
13
|
+
* because a source quiver is content and a link is a reference out of it: followed,
|
|
14
|
+
* `readFile` reads whatever it points at into the quill, where the backend can typeset
|
|
15
|
+
* it and `build` packs it into the published artifact. A linked directory smuggles a
|
|
16
|
+
* tree where a linked file smuggles one, so the answer is the same at each.
|
|
17
|
+
*/
|
|
18
|
+
function refuseSymlink(st, path) {
|
|
19
|
+
if (st.isSymbolicLink()) {
|
|
20
|
+
throw new QuiverError('quiver_invalid', `"${path}" is a symlink; a quiver holds its own content, and nothing under it may point outside`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
10
23
|
/**
|
|
11
24
|
* Scans a Source Quiver root directory.
|
|
12
25
|
*
|
|
@@ -14,8 +27,10 @@ import { isCanonicalSemver, compareSemver } from './semver.js';
|
|
|
14
27
|
* to build a catalog of quill names → sorted versions (descending).
|
|
15
28
|
*
|
|
16
29
|
* Throws:
|
|
17
|
-
* - `quiver_invalid` if Quiver.yaml is missing/invalid, a
|
|
18
|
-
*
|
|
30
|
+
* - `quiver_invalid` if Quiver.yaml is missing/invalid, a quill or version directory
|
|
31
|
+
* is a symlink, a quill dir holding versions is named outside the ref charset, a
|
|
32
|
+
* version dir name is non-canonical, or a version dir is missing its Quill.yaml
|
|
33
|
+
* sentinel.
|
|
19
34
|
* - `transport_error` for I/O failures (permissions, etc.).
|
|
20
35
|
*
|
|
21
36
|
* Missing `quills/` directory is not an error — the quiver is valid but empty.
|
|
@@ -56,11 +71,12 @@ export async function scanSourceQuiver(rootDir) {
|
|
|
56
71
|
const quillNameDir = join(quillsDir, quillName);
|
|
57
72
|
let st;
|
|
58
73
|
try {
|
|
59
|
-
st = await
|
|
74
|
+
st = await lstat(quillNameDir);
|
|
60
75
|
}
|
|
61
76
|
catch (err) {
|
|
62
77
|
throw new QuiverError('transport_error', `Failed to stat "${quillNameDir}": ${err.message}`, { cause: err });
|
|
63
78
|
}
|
|
79
|
+
refuseSymlink(st, quillNameDir);
|
|
64
80
|
if (!st.isDirectory())
|
|
65
81
|
continue;
|
|
66
82
|
let versionDirs;
|
|
@@ -75,11 +91,12 @@ export async function scanSourceQuiver(rootDir) {
|
|
|
75
91
|
const versionPath = join(quillNameDir, versionDir);
|
|
76
92
|
let vst;
|
|
77
93
|
try {
|
|
78
|
-
vst = await
|
|
94
|
+
vst = await lstat(versionPath);
|
|
79
95
|
}
|
|
80
96
|
catch (err) {
|
|
81
97
|
throw new QuiverError('transport_error', `Failed to stat "${versionPath}": ${err.message}`, { cause: err });
|
|
82
98
|
}
|
|
99
|
+
refuseSymlink(vst, versionPath);
|
|
83
100
|
if (!vst.isDirectory())
|
|
84
101
|
continue;
|
|
85
102
|
if (!isCanonicalSemver(versionDir)) {
|
|
@@ -99,6 +116,11 @@ export async function scanSourceQuiver(rootDir) {
|
|
|
99
116
|
versions.push(versionDir);
|
|
100
117
|
}
|
|
101
118
|
if (versions.length > 0) {
|
|
119
|
+
// Asked here rather than at the directory, so a stray one holding no quill
|
|
120
|
+
// stays ignored: what has to be addressable is a catalog row.
|
|
121
|
+
if (!isQuillName(quillName)) {
|
|
122
|
+
throw new QuiverError('quiver_invalid', `Quill directory "${quillName}" is not a name a ref can spell — only [A-Za-z0-9_-] are allowed`, { quiverName: meta.name });
|
|
123
|
+
}
|
|
102
124
|
versions.sort((a, b) => compareSemver(b, a));
|
|
103
125
|
catalog.set(quillName, versions);
|
|
104
126
|
}
|
|
@@ -109,7 +131,7 @@ export async function scanSourceQuiver(rootDir) {
|
|
|
109
131
|
* Recursively reads all files under a quill version directory into a Map.
|
|
110
132
|
*
|
|
111
133
|
* Keys are relative POSIX paths (forward slashes, no leading slash).
|
|
112
|
-
* Throws `transport_error` on I/O failure.
|
|
134
|
+
* Throws `quiver_invalid` on a symlink, `transport_error` on I/O failure.
|
|
113
135
|
*/
|
|
114
136
|
export async function readQuillTree(quillDir) {
|
|
115
137
|
const tree = new Map();
|
|
@@ -128,11 +150,12 @@ async function walkDir(baseDir, currentDir, tree) {
|
|
|
128
150
|
const fullPath = join(currentDir, entry);
|
|
129
151
|
let st;
|
|
130
152
|
try {
|
|
131
|
-
st = await
|
|
153
|
+
st = await lstat(fullPath);
|
|
132
154
|
}
|
|
133
155
|
catch (err) {
|
|
134
156
|
throw new QuiverError('transport_error', `Failed to stat "${fullPath}": ${err.message}`, { cause: err });
|
|
135
157
|
}
|
|
158
|
+
refuseSymlink(st, fullPath);
|
|
136
159
|
if (st.isDirectory()) {
|
|
137
160
|
await walkDir(baseDir, fullPath, tree);
|
|
138
161
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quillmark/quiver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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.
|
|
45
|
+
"@quillmark/wasm": ">=0.108.0-0"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"fflate": "^0.8.2",
|