@cardanvil/frame-kit 0.2.0 → 0.3.1
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 +37 -2
- package/dist/cli/api.d.ts +7 -1
- package/dist/cli/api.js +10 -4
- package/dist/cli/atomic.d.ts +15 -0
- package/dist/cli/atomic.js +114 -0
- package/dist/cli/build.d.ts +4 -1
- package/dist/cli/build.js +9 -5
- package/dist/cli/bundle.js +5 -7
- package/dist/cli/loadFrame.d.ts +10 -0
- package/dist/cli/loadFrame.js +14 -0
- package/dist/cli/main.js +19 -0
- package/dist/cli/watch.d.ts +21 -0
- package/dist/cli/watch.js +164 -0
- package/dist/manifest/contract.d.ts +2 -1
- package/dist/manifest/contract.js +9 -1
- package/dist/manifest/deserialize.d.ts +3 -2
- package/dist/manifest/deserialize.js +71 -1
- package/dist/manifest/serialize.d.ts +1 -0
- package/dist/manifest/serialize.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ keeps the literal types.
|
|
|
56
56
|
```bash
|
|
57
57
|
frame-kit validate # check every frame, write nothing
|
|
58
58
|
frame-kit build --out dist # check, then pack each one
|
|
59
|
+
frame-kit build --out dist --watch # …and repack whenever a source changes
|
|
59
60
|
frame-kit schema meta # a format as JSON Schema
|
|
60
61
|
frame-kit --help
|
|
61
62
|
```
|
|
@@ -73,6 +74,38 @@ reports everything; under GitHub Actions they become inline annotations.
|
|
|
73
74
|
|
|
74
75
|
Exit codes: `0` clean, `1` one or more frames failed, `2` used wrongly.
|
|
75
76
|
|
|
77
|
+
### `--watch`
|
|
78
|
+
|
|
79
|
+
`build --watch` builds once, then rebuilds only the frames whose files changed,
|
|
80
|
+
until you stop it. It is meant to be pointed at a folder Card Anvil is linked to,
|
|
81
|
+
so saving a source file updates the preview without any manual step.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
frame-kit build --out ~/CardAnvilFrames --watch
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A failed rebuild leaves the previous bundle in place and prints the problem — the
|
|
88
|
+
app keeps rendering the last good build rather than losing the frame mid-edit.
|
|
89
|
+
Bundles are written atomically, so a watcher on the other side never reads a
|
|
90
|
+
half-written file. `--watch` refuses to combine with `--json` or `--summary-md`,
|
|
91
|
+
which describe a single run that has finished.
|
|
92
|
+
|
|
93
|
+
Changes are debounced 200 ms, and a rebuild already in flight is allowed to
|
|
94
|
+
finish before the next one starts: two builds writing one bundle would race.
|
|
95
|
+
Output files, dotfiles, `node_modules`, `dist` and editor droppings are ignored,
|
|
96
|
+
so a rebuild cannot trigger itself.
|
|
97
|
+
|
|
98
|
+
Because bundles are byte-deterministic, saving a file that does not change the
|
|
99
|
+
built output produces an identical bundle, and a consumer watching for content
|
|
100
|
+
changes sees nothing happen — which is correct.
|
|
101
|
+
|
|
102
|
+
⚠ **Frames are loaded through a long-lived Vite server**, and its SSR module
|
|
103
|
+
cache is invalidated on each pass. Without that a rebuild would re-serve the
|
|
104
|
+
module Vite loaded first, so an edited layout number would rebuild
|
|
105
|
+
byte-identically and the watch would appear to do nothing. If you embed the
|
|
106
|
+
build API yourself, call `invalidateFrameModules` between passes on a server you
|
|
107
|
+
reuse.
|
|
108
|
+
|
|
76
109
|
Packaging needs Vite — it is what turns `import w from "./w.png"` into a file —
|
|
77
110
|
so it is an **optional** peer dependency, loaded only when packaging runs. The
|
|
78
111
|
schemas above work under plain Node and in a browser without it.
|
|
@@ -129,8 +162,10 @@ package.
|
|
|
129
162
|
|
|
130
163
|
A manifest carries a `contractVersion` of the form `MAJOR.MINOR`, independent of this package's
|
|
131
164
|
version. A differing major is refused; a newer minor loads with a reduced-fidelity warning, because
|
|
132
|
-
added fields are always optional.
|
|
133
|
-
|
|
165
|
+
added fields are always optional. A layout the loader does not recognise is dropped with a
|
|
166
|
+
degraded note rather than failing the frame, so one new layout cannot cost an author every other
|
|
167
|
+
one. Packaging preserves everything the contract defines and drops everything it does not — keys
|
|
168
|
+
outside the schema do not survive the round trip.
|
|
134
169
|
|
|
135
170
|
## Licence
|
|
136
171
|
|
package/dist/cli/api.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Frame } from "../schema/frame.js";
|
|
2
2
|
import { type DiscoveredFrame } from "./discover.js";
|
|
3
|
+
import { type FrameServer } from "./loadFrame.js";
|
|
3
4
|
import { Reporter } from "./report.js";
|
|
4
5
|
export interface LoadedFrame {
|
|
5
6
|
discovered: DiscoveredFrame;
|
|
@@ -24,7 +25,12 @@ export interface LoadOptions {
|
|
|
24
25
|
* Shared by `validate` and `build` so the two can never disagree about whether
|
|
25
26
|
* a frame is sound.
|
|
26
27
|
*/
|
|
27
|
-
export declare function loadFrames(options: LoadOptions, reporter: Reporter
|
|
28
|
+
export declare function loadFrames(options: LoadOptions, reporter: Reporter,
|
|
29
|
+
/**
|
|
30
|
+
* A server to reuse. When supplied the caller owns its lifetime and this
|
|
31
|
+
* function will not close it — watch mode keeps one alive across rebuilds.
|
|
32
|
+
*/
|
|
33
|
+
existingServer?: FrameServer): Promise<LoadedFrame[]>;
|
|
28
34
|
export interface ValidateResult {
|
|
29
35
|
reporter: Reporter;
|
|
30
36
|
/** The frames that loaded cleanly. Fewer than were found, if some failed. */
|
package/dist/cli/api.js
CHANGED
|
@@ -19,7 +19,12 @@ function toPlain(frame) {
|
|
|
19
19
|
* Shared by `validate` and `build` so the two can never disagree about whether
|
|
20
20
|
* a frame is sound.
|
|
21
21
|
*/
|
|
22
|
-
export async function loadFrames(options, reporter
|
|
22
|
+
export async function loadFrames(options, reporter,
|
|
23
|
+
/**
|
|
24
|
+
* A server to reuse. When supplied the caller owns its lifetime and this
|
|
25
|
+
* function will not close it — watch mode keeps one alive across rebuilds.
|
|
26
|
+
*/
|
|
27
|
+
existingServer) {
|
|
23
28
|
const root = path.resolve(options.root);
|
|
24
29
|
const discovered = await discoverFrames(root, reporter);
|
|
25
30
|
const only = new Set(options.only ?? []);
|
|
@@ -40,10 +45,9 @@ export async function loadFrames(options, reporter) {
|
|
|
40
45
|
}
|
|
41
46
|
return [];
|
|
42
47
|
}
|
|
43
|
-
|
|
48
|
+
const frameServer = existingServer ?? (await startFrameServer(root));
|
|
44
49
|
const loaded = [];
|
|
45
50
|
try {
|
|
46
|
-
frameServer = await startFrameServer(root);
|
|
47
51
|
for (const frame of wanted) {
|
|
48
52
|
const result = await loadOne(frameServer, root, frame, reporter);
|
|
49
53
|
if (result) {
|
|
@@ -52,7 +56,9 @@ export async function loadFrames(options, reporter) {
|
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
finally {
|
|
55
|
-
|
|
59
|
+
if (!existingServer) {
|
|
60
|
+
await frameServer.close();
|
|
61
|
+
}
|
|
56
62
|
}
|
|
57
63
|
return loaded;
|
|
58
64
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes `file` so that a reader never sees a partial one.
|
|
3
|
+
*
|
|
4
|
+
* Watch mode means something else is reading this directory while it is being
|
|
5
|
+
* written — Card Anvil re-reads a bundle the moment it changes. Writing in
|
|
6
|
+
* place would hand it a truncated zip whenever the timing was unlucky, so
|
|
7
|
+
* every output is written to a temp file and renamed into position, which is
|
|
8
|
+
* atomic on every platform this runs on — see {@link renameWhenFree} for the
|
|
9
|
+
* one thing Windows asks for in return.
|
|
10
|
+
*/
|
|
11
|
+
export declare function writeFileAtomic(file: string, data: string | Uint8Array): Promise<void>;
|
|
12
|
+
/** Streams to `file` atomically. Returns the number of bytes written. */
|
|
13
|
+
export declare function writeStreamAtomic(file: string, stream: NodeJS.ReadableStream): Promise<number>;
|
|
14
|
+
/** Copies to `dest` atomically. */
|
|
15
|
+
export declare function copyFileAtomic(source: string, dest: string): Promise<void>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { copyFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
/**
|
|
7
|
+
* A temp name beside the destination.
|
|
8
|
+
*
|
|
9
|
+
* Beside, not in the system temp directory: `rename` is only atomic within one
|
|
10
|
+
* filesystem, and an author's output directory is frequently on a different
|
|
11
|
+
* volume from /tmp.
|
|
12
|
+
*/
|
|
13
|
+
function tempFor(file) {
|
|
14
|
+
return path.join(path.dirname(file), `.${path.basename(file)}.${String(process.pid)}.tmp`);
|
|
15
|
+
}
|
|
16
|
+
async function discard(temp) {
|
|
17
|
+
await rm(temp, { force: true });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What Windows reports when something else has the destination open.
|
|
21
|
+
*
|
|
22
|
+
* `EBUSY` and `EACCES` for the same situation on network shares and some
|
|
23
|
+
* antivirus filters.
|
|
24
|
+
*/
|
|
25
|
+
const HELD_OPEN = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
26
|
+
/** Backoff between rename attempts. */
|
|
27
|
+
const RETRY_DELAYS_MS = [10, 20, 40, 80, 160, 320, 640, 1000];
|
|
28
|
+
const TOTAL_WAIT_MS = RETRY_DELAYS_MS.reduce((a, b) => a + b, 0);
|
|
29
|
+
/**
|
|
30
|
+
* Renames, waiting out anything that has the destination open.
|
|
31
|
+
*
|
|
32
|
+
* POSIX lets a file be replaced while it is being read. Windows does not: a
|
|
33
|
+
* rename onto a path some process holds open fails outright. A linked folder
|
|
34
|
+
* exists precisely to be read — Card Anvil re-reads a bundle whenever it
|
|
35
|
+
* changes — so a rebuild that lands mid-read hits this, and so do antivirus
|
|
36
|
+
* and search indexers, which open files briefly on their own schedule.
|
|
37
|
+
*
|
|
38
|
+
* Waiting is the whole fix: whatever holds the file is finishing a read that
|
|
39
|
+
* takes milliseconds. A real permission problem still fails, two seconds later.
|
|
40
|
+
*/
|
|
41
|
+
async function renameWhenFree(temp, file) {
|
|
42
|
+
for (let attempt = 0;; attempt++) {
|
|
43
|
+
try {
|
|
44
|
+
await rename(temp, file);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
catch (cause) {
|
|
48
|
+
const code = cause.code ?? "";
|
|
49
|
+
if (!HELD_OPEN.has(code)) {
|
|
50
|
+
throw cause;
|
|
51
|
+
}
|
|
52
|
+
const wait = RETRY_DELAYS_MS[attempt];
|
|
53
|
+
if (wait === undefined) {
|
|
54
|
+
// `EPERM: operation not permitted, rename` says nothing about what to
|
|
55
|
+
// do. Whatever is holding the file is the thing to go and close.
|
|
56
|
+
throw new Error(`could not replace ${path.basename(file)}: something still has it open after ${String(TOTAL_WAIT_MS)}ms (${code}). On Windows a file cannot be replaced while another process holds it — close whatever is reading it, or point the build somewhere else.`, { cause });
|
|
57
|
+
}
|
|
58
|
+
await delay(wait);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Writes `file` so that a reader never sees a partial one.
|
|
64
|
+
*
|
|
65
|
+
* Watch mode means something else is reading this directory while it is being
|
|
66
|
+
* written — Card Anvil re-reads a bundle the moment it changes. Writing in
|
|
67
|
+
* place would hand it a truncated zip whenever the timing was unlucky, so
|
|
68
|
+
* every output is written to a temp file and renamed into position, which is
|
|
69
|
+
* atomic on every platform this runs on — see {@link renameWhenFree} for the
|
|
70
|
+
* one thing Windows asks for in return.
|
|
71
|
+
*/
|
|
72
|
+
export async function writeFileAtomic(file, data) {
|
|
73
|
+
const temp = tempFor(file);
|
|
74
|
+
try {
|
|
75
|
+
await writeFile(temp, data);
|
|
76
|
+
await renameWhenFree(temp, file);
|
|
77
|
+
}
|
|
78
|
+
catch (cause) {
|
|
79
|
+
await discard(temp);
|
|
80
|
+
throw cause;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Streams to `file` atomically. Returns the number of bytes written. */
|
|
84
|
+
export async function writeStreamAtomic(file,
|
|
85
|
+
// The broad interface rather than node:stream's Readable, because yazl types
|
|
86
|
+
// its output stream this way.
|
|
87
|
+
stream) {
|
|
88
|
+
const temp = tempFor(file);
|
|
89
|
+
let bytes = 0;
|
|
90
|
+
stream.on("data", (chunk) => {
|
|
91
|
+
bytes += chunk.length;
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
await pipeline(stream, createWriteStream(temp));
|
|
95
|
+
await renameWhenFree(temp, file);
|
|
96
|
+
return bytes;
|
|
97
|
+
}
|
|
98
|
+
catch (cause) {
|
|
99
|
+
await discard(temp);
|
|
100
|
+
throw cause;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Copies to `dest` atomically. */
|
|
104
|
+
export async function copyFileAtomic(source, dest) {
|
|
105
|
+
const temp = tempFor(dest);
|
|
106
|
+
try {
|
|
107
|
+
await copyFile(source, temp);
|
|
108
|
+
await renameWhenFree(temp, dest);
|
|
109
|
+
}
|
|
110
|
+
catch (cause) {
|
|
111
|
+
await discard(temp);
|
|
112
|
+
throw cause;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/cli/build.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FrameManifest } from "../manifest/index.js";
|
|
2
2
|
import { type LoadOptions, type LoadedFrame } from "./api.js";
|
|
3
3
|
import { type FrameIndex, type IndexedFrame, type ReleaseSource } from "./frameIndex.js";
|
|
4
|
+
import type { FrameServer } from "./loadFrame.js";
|
|
4
5
|
import type { Reporter } from "./report.js";
|
|
5
6
|
export interface BuildOptions extends LoadOptions {
|
|
6
7
|
/** Directory bundles and the index are written to. Created if missing. */
|
|
@@ -33,4 +34,6 @@ export interface BuildResult {
|
|
|
33
34
|
* Frames that fail to load are reported and skipped rather than aborting the
|
|
34
35
|
* run, so one bad frame does not hide the state of the others.
|
|
35
36
|
*/
|
|
36
|
-
export declare function buildFrames(options: BuildOptions, reporter: Reporter
|
|
37
|
+
export declare function buildFrames(options: BuildOptions, reporter: Reporter,
|
|
38
|
+
/** A server to reuse. Passed straight to loadFrames; see its note. */
|
|
39
|
+
existingServer?: FrameServer): Promise<BuildResult>;
|
package/dist/cli/build.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { FrameManifestSchema, frameToManifest, manifestToFrame, } from "../manifest/index.js";
|
|
4
4
|
import { DEFAULT_CANVAS_HEIGHT, DEFAULT_CANVAS_WIDTH, } from "../schema/frame.js";
|
|
5
5
|
import { loadFrames } from "./api.js";
|
|
6
|
+
import { copyFileAtomic, writeFileAtomic } from "./atomic.js";
|
|
6
7
|
import { describeBytes, writeBundle } from "./bundle.js";
|
|
7
8
|
import { PackagingError } from "./errors.js";
|
|
8
9
|
import { FRAME_INDEX_FILENAME, buildIndex, downloadBase, } from "./frameIndex.js";
|
|
@@ -20,12 +21,14 @@ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
|
20
21
|
* Frames that fail to load are reported and skipped rather than aborting the
|
|
21
22
|
* run, so one bad frame does not hide the state of the others.
|
|
22
23
|
*/
|
|
23
|
-
export async function buildFrames(options, reporter
|
|
24
|
+
export async function buildFrames(options, reporter,
|
|
25
|
+
/** A server to reuse. Passed straight to loadFrames; see its note. */
|
|
26
|
+
existingServer) {
|
|
24
27
|
if (!VERSION_PATTERN.test(options.version)) {
|
|
25
28
|
throw new PackagingError(`"${options.version}" is not a version. Expected MAJOR.MINOR.PATCH, ` +
|
|
26
29
|
`optionally with a pre-release suffix.`);
|
|
27
30
|
}
|
|
28
|
-
const loaded = await loadFrames(options, reporter);
|
|
31
|
+
const loaded = await loadFrames(options, reporter, existingServer);
|
|
29
32
|
const outDir = path.resolve(options.outDir);
|
|
30
33
|
await mkdir(outDir, { recursive: true });
|
|
31
34
|
const built = [];
|
|
@@ -46,7 +49,7 @@ export async function buildFrames(options, reporter) {
|
|
|
46
49
|
...(options.source === undefined ? {} : { source: options.source }),
|
|
47
50
|
frames: built.map((frame) => frame.entry),
|
|
48
51
|
});
|
|
49
|
-
await
|
|
52
|
+
await writeFileAtomic(path.join(outDir, FRAME_INDEX_FILENAME), stableStringify(index));
|
|
50
53
|
return { frames: built, index };
|
|
51
54
|
}
|
|
52
55
|
async function buildOne(loaded, outDir, options, reporter) {
|
|
@@ -65,6 +68,7 @@ async function buildOne(loaded, outDir, options, reporter) {
|
|
|
65
68
|
? {}
|
|
66
69
|
: { generator: options.generator }),
|
|
67
70
|
author: loaded.discovered.meta.author,
|
|
71
|
+
license: loaded.discovered.meta.license,
|
|
68
72
|
resolveAsset: (url) => {
|
|
69
73
|
const asset = byUrl.get(url);
|
|
70
74
|
if (asset === undefined) {
|
|
@@ -179,7 +183,7 @@ async function indexEntry(loaded, manifest, bundle, fileByPath, outDir, options)
|
|
|
179
183
|
throw new PackagingError(`the preview image is not among the packaged assets — this is a bug in frame-kit`);
|
|
180
184
|
}
|
|
181
185
|
const previewName = `${slug}-${options.version}.preview${path.extname(previewSource).toLowerCase()}`;
|
|
182
|
-
await
|
|
186
|
+
await copyFileAtomic(previewSource, path.join(outDir, previewName));
|
|
183
187
|
const [previewHash, previewStats, size, bundleHash] = await Promise.all([
|
|
184
188
|
hashFile(previewSource),
|
|
185
189
|
stat(previewSource),
|
package/dist/cli/bundle.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { createWriteStream } from "node:fs";
|
|
2
|
-
import { pipeline } from "node:stream/promises";
|
|
3
1
|
import yazl from "yazl";
|
|
2
|
+
import { writeStreamAtomic } from "./atomic.js";
|
|
4
3
|
import { PackagingError } from "./errors.js";
|
|
5
4
|
import { stableStringify } from "./stableJson.js";
|
|
6
5
|
/** The earliest instant a DOS timestamp can express. */
|
|
@@ -41,11 +40,10 @@ export async function writeBundle(outFile, manifest, source) {
|
|
|
41
40
|
});
|
|
42
41
|
}
|
|
43
42
|
zip.end();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
await pipeline(zip.outputStream, createWriteStream(outFile));
|
|
43
|
+
// Written to a temp file and renamed: watch mode means Card Anvil may be
|
|
44
|
+
// reading this directory while it is being written, and a partial zip is
|
|
45
|
+
// indistinguishable from a corrupt one.
|
|
46
|
+
const bytes = await writeStreamAtomic(outFile, zip.outputStream);
|
|
49
47
|
if (bytes > MAX_BUNDLE_BYTES) {
|
|
50
48
|
throw new PackagingError(`${outFile} is ${describeBytes(bytes)}, over GitHub's 2 GiB limit for a ` +
|
|
51
49
|
`release asset. Split the frame, or use smaller art.`);
|
package/dist/cli/loadFrame.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { ViteDevServer } from "vite";
|
|
2
|
+
/**
|
|
3
|
+
* Drops the SSR module graph so the next load re-reads from disk.
|
|
4
|
+
*
|
|
5
|
+
* Watch mode reuses one server across rebuilds, because starting one costs a
|
|
6
|
+
* second or two and a fresh dependency cache. Without this, ssrLoadModule
|
|
7
|
+
* serves the module it loaded the first time, and a rebuild produces
|
|
8
|
+
* byte-identical output no matter what the author just changed — the watch
|
|
9
|
+
* loop would appear to run and do nothing.
|
|
10
|
+
*/
|
|
11
|
+
export declare function invalidateFrameModules(frameServer: FrameServer): void;
|
|
2
12
|
export interface FrameServer {
|
|
3
13
|
readonly server: ViteDevServer;
|
|
4
14
|
/** Absolute project root every asset URL is relative to. */
|
package/dist/cli/loadFrame.js
CHANGED
|
@@ -22,6 +22,20 @@ async function importVite() {
|
|
|
22
22
|
" pnpm add -D vite\n");
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Drops the SSR module graph so the next load re-reads from disk.
|
|
27
|
+
*
|
|
28
|
+
* Watch mode reuses one server across rebuilds, because starting one costs a
|
|
29
|
+
* second or two and a fresh dependency cache. Without this, ssrLoadModule
|
|
30
|
+
* serves the module it loaded the first time, and a rebuild produces
|
|
31
|
+
* byte-identical output no matter what the author just changed — the watch
|
|
32
|
+
* loop would appear to run and do nothing.
|
|
33
|
+
*/
|
|
34
|
+
export function invalidateFrameModules(frameServer) {
|
|
35
|
+
// Both supported Vite majors expose the SSR module graph here; the legacy
|
|
36
|
+
// top-level accessor is deprecated.
|
|
37
|
+
frameServer.server.environments.ssr.moduleGraph.invalidateAll();
|
|
38
|
+
}
|
|
25
39
|
/**
|
|
26
40
|
* Starts one Vite server for a whole packaging run.
|
|
27
41
|
*
|
package/dist/cli/main.js
CHANGED
|
@@ -9,6 +9,7 @@ import { PackagingError } from "./errors.js";
|
|
|
9
9
|
import { Reporter, inGitHubActions } from "./report.js";
|
|
10
10
|
import { SCHEMA_NAMES, toJsonSchema } from "./schema.js";
|
|
11
11
|
import { stableStringify } from "./stableJson.js";
|
|
12
|
+
import { watchFrames } from "./watch.js";
|
|
12
13
|
const USAGE = `frame-kit — package Card Anvil frames
|
|
13
14
|
|
|
14
15
|
Usage:
|
|
@@ -32,6 +33,7 @@ build only:
|
|
|
32
33
|
--repository <o/n> the GitHub repository the release belongs to
|
|
33
34
|
--tag <vX.Y.Z> the release tag; with --repository, puts download URLs
|
|
34
35
|
in the index
|
|
36
|
+
--watch rebuild a frame whenever its source changes
|
|
35
37
|
|
|
36
38
|
A frame is any directory containing a frame.meta.json.
|
|
37
39
|
`;
|
|
@@ -100,6 +102,22 @@ async function runBuild(root, only, values) {
|
|
|
100
102
|
return EXIT.usage;
|
|
101
103
|
}
|
|
102
104
|
const version = values.version ?? (await repositoryVersion(root));
|
|
105
|
+
if (values.watch) {
|
|
106
|
+
// Both are one-shot shapes meant for CI, and neither means anything for a
|
|
107
|
+
// process that never finishes.
|
|
108
|
+
if (values.json || values["summary-md"] !== undefined) {
|
|
109
|
+
console.error("--watch cannot be combined with --json or --summary-md.");
|
|
110
|
+
return EXIT.usage;
|
|
111
|
+
}
|
|
112
|
+
await watchFrames({
|
|
113
|
+
root,
|
|
114
|
+
only,
|
|
115
|
+
outDir: values.out,
|
|
116
|
+
version,
|
|
117
|
+
generator: `@cardanvil/frame-kit@${await packageVersion()}`,
|
|
118
|
+
});
|
|
119
|
+
return EXIT.ok;
|
|
120
|
+
}
|
|
103
121
|
const reporter = new Reporter();
|
|
104
122
|
const { frames: built } = await buildFrames({
|
|
105
123
|
root,
|
|
@@ -198,6 +216,7 @@ async function main(argv) {
|
|
|
198
216
|
version: { type: "string" },
|
|
199
217
|
repository: { type: "string" },
|
|
200
218
|
tag: { type: "string" },
|
|
219
|
+
watch: { type: "boolean" },
|
|
201
220
|
},
|
|
202
221
|
strict: true,
|
|
203
222
|
}));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type BuildOptions } from "./build.js";
|
|
2
|
+
export interface WatchOptions extends BuildOptions {
|
|
3
|
+
/** Called after every rebuild so the CLI can report it. */
|
|
4
|
+
onBuild?: (summary: string) => void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Rebuilds frames as their sources change, until the process is interrupted.
|
|
8
|
+
*
|
|
9
|
+
* One Vite server stays alive for the whole session — starting one costs a
|
|
10
|
+
* second or two — and its module graph is dropped before each rebuild so an
|
|
11
|
+
* edit is actually seen. See `invalidateFrameModules`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function watchFrames(options: WatchOptions): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Which frames a set of changed paths affects.
|
|
16
|
+
*
|
|
17
|
+
* `undefined` means "rebuild everything" — a descriptor appeared or vanished,
|
|
18
|
+
* or something outside every frame directory changed (a shared module, a
|
|
19
|
+
* workspace file), and we cannot know what depends on it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function slugsFor(root: string, changed: readonly string[], only: readonly string[] | undefined): Promise<string[] | undefined>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildFrames } from "./build.js";
|
|
4
|
+
import { discoverFrames, relativePosix } from "./discover.js";
|
|
5
|
+
import { invalidateFrameModules, startFrameServer } from "./loadFrame.js";
|
|
6
|
+
import { Reporter, formatProblem } from "./report.js";
|
|
7
|
+
/** Directories never worth watching. Mirrors discovery's own skip list. */
|
|
8
|
+
const SKIP_DIRS = new Set([
|
|
9
|
+
"node_modules",
|
|
10
|
+
"dist",
|
|
11
|
+
".git",
|
|
12
|
+
".pnpm-store",
|
|
13
|
+
"__fixtures__",
|
|
14
|
+
]);
|
|
15
|
+
/** Files a rebuild produces, or an editor leaves lying around. */
|
|
16
|
+
const IGNORED_FILE = /(\.cardframe|frame-index\.json|\.preview\.[a-z]+|~|\.swp|\.tmp)$/i;
|
|
17
|
+
/**
|
|
18
|
+
* How long to wait for changes to stop before rebuilding.
|
|
19
|
+
*
|
|
20
|
+
* One save can produce several events — a write, a rename, an attribute
|
|
21
|
+
* change — and a tool upstream may rewrite a whole directory.
|
|
22
|
+
*/
|
|
23
|
+
const DEBOUNCE_MS = 200;
|
|
24
|
+
function isIgnored(file) {
|
|
25
|
+
const segments = file.split(path.sep);
|
|
26
|
+
return (segments.some((segment) => SKIP_DIRS.has(segment) || segment.startsWith(".")) || IGNORED_FILE.test(file));
|
|
27
|
+
}
|
|
28
|
+
const plural = (n) => `${String(n)} frame${n === 1 ? "" : "s"}`;
|
|
29
|
+
/**
|
|
30
|
+
* Rebuilds frames as their sources change, until the process is interrupted.
|
|
31
|
+
*
|
|
32
|
+
* One Vite server stays alive for the whole session — starting one costs a
|
|
33
|
+
* second or two — and its module graph is dropped before each rebuild so an
|
|
34
|
+
* edit is actually seen. See `invalidateFrameModules`.
|
|
35
|
+
*/
|
|
36
|
+
export async function watchFrames(options) {
|
|
37
|
+
const root = path.resolve(options.root);
|
|
38
|
+
const outDir = path.resolve(options.outDir);
|
|
39
|
+
const report = options.onBuild ??
|
|
40
|
+
((summary) => {
|
|
41
|
+
console.log(summary);
|
|
42
|
+
});
|
|
43
|
+
const frameServer = await startFrameServer(root);
|
|
44
|
+
const watchers = [];
|
|
45
|
+
const pending = new Set();
|
|
46
|
+
let timer;
|
|
47
|
+
let building = false;
|
|
48
|
+
function runBuild(only) {
|
|
49
|
+
invalidateFrameModules(frameServer);
|
|
50
|
+
const reporter = new Reporter();
|
|
51
|
+
const started = Date.now();
|
|
52
|
+
return buildFrames({ ...options, ...(only === undefined ? {} : { only }) }, reporter, frameServer).then(({ frames }) => {
|
|
53
|
+
for (const problem of reporter.problems) {
|
|
54
|
+
console.error(`${problem.severity}: ${formatProblem(problem)}`);
|
|
55
|
+
}
|
|
56
|
+
report(`${plural(frames.length)} rebuilt in ${String(Date.now() - started)}ms`);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function drain() {
|
|
60
|
+
if (building) {
|
|
61
|
+
// Two builds would race on the same output file. Whatever arrived is
|
|
62
|
+
// already in `pending`, and the loop below will pick it up.
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
building = true;
|
|
66
|
+
try {
|
|
67
|
+
// Keyed off `pending` rather than a flag: anything that arrived during a
|
|
68
|
+
// build is still queued when it finishes.
|
|
69
|
+
while (pending.size > 0) {
|
|
70
|
+
const changed = [...pending];
|
|
71
|
+
pending.clear();
|
|
72
|
+
const slugs = await slugsFor(root, changed, options.only);
|
|
73
|
+
if (slugs?.length === 0) {
|
|
74
|
+
continue; // nothing we build was touched
|
|
75
|
+
}
|
|
76
|
+
await runBuild(slugs);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (cause) {
|
|
80
|
+
// A failed rebuild must not end the session: the author is mid-edit and
|
|
81
|
+
// the next save is probably the fix.
|
|
82
|
+
console.error(cause instanceof Error ? cause.message : String(cause));
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
building = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function onChange(file) {
|
|
89
|
+
if (isIgnored(file) || file.startsWith(outDir)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
pending.add(file);
|
|
93
|
+
if (timer) {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
timer = setTimeout(() => void drain(), DEBOUNCE_MS);
|
|
97
|
+
}
|
|
98
|
+
function watchDir(dir) {
|
|
99
|
+
try {
|
|
100
|
+
watchers.push(watch(dir, { recursive: true }, (_event, name) => {
|
|
101
|
+
if (name !== null) {
|
|
102
|
+
onChange(path.join(dir, name));
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
console.error(`Could not watch ${relativePosix(root, dir)}. Recursive watching is ` +
|
|
108
|
+
`not supported on this filesystem; rebuild with \`frame-kit build\`.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
await runBuild(undefined);
|
|
113
|
+
// Each frame's own directory, plus the root, so a frame added or removed
|
|
114
|
+
// while watching is noticed.
|
|
115
|
+
for (const frame of await discoverFrames(root, new Reporter())) {
|
|
116
|
+
watchDir(frame.dir);
|
|
117
|
+
}
|
|
118
|
+
watchDir(root);
|
|
119
|
+
report("Watching for changes. Press Ctrl+C to stop.");
|
|
120
|
+
await new Promise((resolve) => {
|
|
121
|
+
const stop = () => {
|
|
122
|
+
resolve();
|
|
123
|
+
};
|
|
124
|
+
process.once("SIGINT", stop);
|
|
125
|
+
process.once("SIGTERM", stop);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
if (timer) {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
for (const watcher of watchers) {
|
|
133
|
+
watcher.close();
|
|
134
|
+
}
|
|
135
|
+
await frameServer.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Which frames a set of changed paths affects.
|
|
140
|
+
*
|
|
141
|
+
* `undefined` means "rebuild everything" — a descriptor appeared or vanished,
|
|
142
|
+
* or something outside every frame directory changed (a shared module, a
|
|
143
|
+
* workspace file), and we cannot know what depends on it.
|
|
144
|
+
*/
|
|
145
|
+
export async function slugsFor(root, changed, only) {
|
|
146
|
+
if (changed.some((file) => path.basename(file) === "frame.meta.json")) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const discovered = await discoverFrames(root, new Reporter());
|
|
150
|
+
const wanted = new Set(only ?? []);
|
|
151
|
+
const slugs = new Set();
|
|
152
|
+
for (const file of changed) {
|
|
153
|
+
const owner = discovered
|
|
154
|
+
.filter((frame) => file.startsWith(frame.dir + path.sep))
|
|
155
|
+
.sort((a, b) => b.dir.length - a.dir.length)[0];
|
|
156
|
+
if (!owner) {
|
|
157
|
+
return undefined; // outside every frame — something shared changed
|
|
158
|
+
}
|
|
159
|
+
if (wanted.size === 0 || wanted.has(owner.slug)) {
|
|
160
|
+
slugs.add(owner.slug);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return [...slugs];
|
|
164
|
+
}
|
|
@@ -11,7 +11,7 @@ import { z } from "zod";
|
|
|
11
11
|
* because parsing strips unknown keys; they should warn that the frame may
|
|
12
12
|
* render with reduced fidelity.
|
|
13
13
|
*/
|
|
14
|
-
export declare const CONTRACT_VERSION = "1.
|
|
14
|
+
export declare const CONTRACT_VERSION = "1.1";
|
|
15
15
|
/** The only manifest kind any loader understands today. */
|
|
16
16
|
export declare const DECLARATIVE_KIND = "declarative";
|
|
17
17
|
export declare const AssetEntrySchema: z.ZodObject<{
|
|
@@ -30,6 +30,7 @@ export declare const FrameManifestSchema: z.ZodObject<{
|
|
|
30
30
|
name: z.ZodString;
|
|
31
31
|
url: z.ZodOptional<z.ZodString>;
|
|
32
32
|
}, z.core.$strip>>;
|
|
33
|
+
license: z.ZodOptional<z.ZodString>;
|
|
33
34
|
frame: z.ZodObject<{
|
|
34
35
|
name: z.ZodString;
|
|
35
36
|
description: z.ZodString;
|
|
@@ -12,7 +12,7 @@ import { FrameSchema } from "../schema/frame.js";
|
|
|
12
12
|
* because parsing strips unknown keys; they should warn that the frame may
|
|
13
13
|
* render with reduced fidelity.
|
|
14
14
|
*/
|
|
15
|
-
export const CONTRACT_VERSION = "1.
|
|
15
|
+
export const CONTRACT_VERSION = "1.1";
|
|
16
16
|
/** The only manifest kind any loader understands today. */
|
|
17
17
|
export const DECLARATIVE_KIND = "declarative";
|
|
18
18
|
export const AssetEntrySchema = z.object({
|
|
@@ -36,6 +36,14 @@ export const FrameManifestSchema = z.object({
|
|
|
36
36
|
version: z.string(),
|
|
37
37
|
generator: z.string().optional(),
|
|
38
38
|
author: z.object({ name: z.string(), url: z.string().optional() }).optional(),
|
|
39
|
+
/**
|
|
40
|
+
* How the frame's art is licensed, as declared in its descriptor.
|
|
41
|
+
*
|
|
42
|
+
* Optional because contract 1.0 bundles predate it. Added in 1.1 so an
|
|
43
|
+
* installed frame carries its licence with it — before this it existed only
|
|
44
|
+
* in the release index, which an installed bundle does not travel with.
|
|
45
|
+
*/
|
|
46
|
+
license: z.string().optional(),
|
|
39
47
|
/** The frame itself, with every asset URL rewritten to a relative path. */
|
|
40
48
|
frame: FrameSchema,
|
|
41
49
|
/** Integrity index for the packaged files. Loaders may ignore it. */
|
|
@@ -7,8 +7,9 @@ export interface ManifestToFrameOptions {
|
|
|
7
7
|
*/
|
|
8
8
|
resolveUrl: (path: string) => string;
|
|
9
9
|
/**
|
|
10
|
-
* Called when the
|
|
11
|
-
*
|
|
10
|
+
* Called when the frame loads but not in full fidelity — it targets a newer
|
|
11
|
+
* minor contract than this build, or it configures layouts this build does
|
|
12
|
+
* not know about. Called once per reason.
|
|
12
13
|
*/
|
|
13
14
|
onDegraded?: (reason: string) => void;
|
|
14
15
|
}
|
|
@@ -1,12 +1,82 @@
|
|
|
1
1
|
import { FrameSchema } from "../schema/frame.js";
|
|
2
|
+
import { layouts } from "../schema/layout.js";
|
|
2
3
|
import { FrameManifestSchema, assertDeclarative, isContractCompatible, } from "./contract.js";
|
|
3
4
|
import { setAtPath, walkAssets } from "./walk.js";
|
|
5
|
+
const KNOWN_LAYOUTS = new Set(layouts);
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
/** Filters one layout map, recording every key it had to drop. */
|
|
10
|
+
function pruneLayoutMap(value, dropped) {
|
|
11
|
+
if (!isRecord(value)) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
const unknown = Object.keys(value).filter((key) => !KNOWN_LAYOUTS.has(key));
|
|
15
|
+
if (unknown.length === 0) {
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
dropped.push(...unknown);
|
|
19
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => KNOWN_LAYOUTS.has(key)));
|
|
20
|
+
}
|
|
21
|
+
/** Filters every variant's layout map under `config.alternateLayouts`. */
|
|
22
|
+
function pruneAlternateLayouts(value, dropped) {
|
|
23
|
+
if (!isRecord(value)) {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
const next = {};
|
|
27
|
+
let changed = false;
|
|
28
|
+
for (const [variant, map] of Object.entries(value)) {
|
|
29
|
+
const pruned = pruneLayoutMap(map, dropped);
|
|
30
|
+
changed ||= pruned !== map;
|
|
31
|
+
next[variant] = pruned;
|
|
32
|
+
}
|
|
33
|
+
return changed ? next : value;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Strips layout keys this build does not recognise, before the manifest is
|
|
37
|
+
* parsed.
|
|
38
|
+
*
|
|
39
|
+
* `config.layouts` is a `partialRecord` over a closed enum, so an unknown key
|
|
40
|
+
* is a hard error rather than a stripped one — the one place a minor contract
|
|
41
|
+
* bump can still reject a whole frame outright, which is exactly what the
|
|
42
|
+
* degrade policy exists to prevent. Dropping the key costs that one layout and
|
|
43
|
+
* keeps the rest of the frame.
|
|
44
|
+
*
|
|
45
|
+
* Returns the manifest untouched when there is nothing to drop, so the ordinary
|
|
46
|
+
* case does not pay for a copy.
|
|
47
|
+
*/
|
|
48
|
+
function dropUnknownLayouts(manifest, onDegraded) {
|
|
49
|
+
if (!isRecord(manifest) || !isRecord(manifest.frame)) {
|
|
50
|
+
return manifest;
|
|
51
|
+
}
|
|
52
|
+
const frame = manifest.frame;
|
|
53
|
+
const config = frame.config;
|
|
54
|
+
if (!isRecord(config)) {
|
|
55
|
+
return manifest;
|
|
56
|
+
}
|
|
57
|
+
const dropped = [];
|
|
58
|
+
const prunedLayouts = pruneLayoutMap(config.layouts, dropped);
|
|
59
|
+
const prunedAlternates = pruneAlternateLayouts(config.alternateLayouts, dropped);
|
|
60
|
+
if (dropped.length === 0) {
|
|
61
|
+
return manifest;
|
|
62
|
+
}
|
|
63
|
+
const names = [...new Set(dropped)].sort();
|
|
64
|
+
onDegraded?.(`ignoring ${String(names.length)} unknown layout(s) this build does not support: ${names.join(", ")}`);
|
|
65
|
+
const nextConfig = {
|
|
66
|
+
...config,
|
|
67
|
+
layouts: prunedLayouts,
|
|
68
|
+
};
|
|
69
|
+
if (prunedAlternates !== undefined) {
|
|
70
|
+
nextConfig.alternateLayouts = prunedAlternates;
|
|
71
|
+
}
|
|
72
|
+
return { ...manifest, frame: { ...frame, config: nextConfig } };
|
|
73
|
+
}
|
|
4
74
|
/**
|
|
5
75
|
* Validates a manifest and rebuilds a loadable {@link Frame} from it, turning
|
|
6
76
|
* every package-relative asset path back into a URL.
|
|
7
77
|
*/
|
|
8
78
|
export function manifestToFrame(manifest, options) {
|
|
9
|
-
const parsed = FrameManifestSchema.parse(manifest);
|
|
79
|
+
const parsed = FrameManifestSchema.parse(dropUnknownLayouts(manifest, options.onDegraded));
|
|
10
80
|
const compatibility = isContractCompatible(parsed.contractVersion);
|
|
11
81
|
if (!compatibility.ok) {
|
|
12
82
|
throw new Error(compatibility.reason ?? "incompatible contract version");
|
|
@@ -37,6 +37,7 @@ export function frameToManifest(frame, options) {
|
|
|
37
37
|
? {}
|
|
38
38
|
: { generator: options.generator }),
|
|
39
39
|
...(options.author === undefined ? {} : { author: options.author }),
|
|
40
|
+
...(options.license === undefined ? {} : { license: options.license }),
|
|
40
41
|
frame: rewritten,
|
|
41
42
|
// Sorted so the manifest is byte-stable across builds and diffs cleanly.
|
|
42
43
|
assets: [...assetsByPath.values()].sort((a, b) => a.path.localeCompare(b.path)),
|
package/package.json
CHANGED