@cardanvil/frame-kit 0.2.0 → 0.3.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
@@ -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. Packaging preserves everything the contract defines and drops
133
- everything it does not — keys outside the schema do not survive the round trip.
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): Promise<LoadedFrame[]>;
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
- let frameServer;
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
- await frameServer?.close();
59
+ if (!existingServer) {
60
+ await frameServer.close();
61
+ }
56
62
  }
57
63
  return loaded;
58
64
  }
@@ -0,0 +1,14 @@
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.
9
+ */
10
+ export declare function writeFileAtomic(file: string, data: string | Uint8Array): Promise<void>;
11
+ /** Streams to `file` atomically. Returns the number of bytes written. */
12
+ export declare function writeStreamAtomic(file: string, stream: NodeJS.ReadableStream): Promise<number>;
13
+ /** Copies to `dest` atomically. */
14
+ export declare function copyFileAtomic(source: string, dest: string): Promise<void>;
@@ -0,0 +1,69 @@
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
+ /**
6
+ * A temp name beside the destination.
7
+ *
8
+ * Beside, not in the system temp directory: `rename` is only atomic within one
9
+ * filesystem, and an author's output directory is frequently on a different
10
+ * volume from /tmp.
11
+ */
12
+ function tempFor(file) {
13
+ return path.join(path.dirname(file), `.${path.basename(file)}.${String(process.pid)}.tmp`);
14
+ }
15
+ async function discard(temp) {
16
+ await rm(temp, { force: true });
17
+ }
18
+ /**
19
+ * Writes `file` so that a reader never sees a partial one.
20
+ *
21
+ * Watch mode means something else is reading this directory while it is being
22
+ * written — Card Anvil re-reads a bundle the moment it changes. Writing in
23
+ * place would hand it a truncated zip whenever the timing was unlucky, so
24
+ * every output is written to a temp file and renamed into position, which is
25
+ * atomic on every platform this runs on.
26
+ */
27
+ export async function writeFileAtomic(file, data) {
28
+ const temp = tempFor(file);
29
+ try {
30
+ await writeFile(temp, data);
31
+ await rename(temp, file);
32
+ }
33
+ catch (cause) {
34
+ await discard(temp);
35
+ throw cause;
36
+ }
37
+ }
38
+ /** Streams to `file` atomically. Returns the number of bytes written. */
39
+ export async function writeStreamAtomic(file,
40
+ // The broad interface rather than node:stream's Readable, because yazl types
41
+ // its output stream this way.
42
+ stream) {
43
+ const temp = tempFor(file);
44
+ let bytes = 0;
45
+ stream.on("data", (chunk) => {
46
+ bytes += chunk.length;
47
+ });
48
+ try {
49
+ await pipeline(stream, createWriteStream(temp));
50
+ await rename(temp, file);
51
+ return bytes;
52
+ }
53
+ catch (cause) {
54
+ await discard(temp);
55
+ throw cause;
56
+ }
57
+ }
58
+ /** Copies to `dest` atomically. */
59
+ export async function copyFileAtomic(source, dest) {
60
+ const temp = tempFor(dest);
61
+ try {
62
+ await copyFile(source, temp);
63
+ await rename(temp, dest);
64
+ }
65
+ catch (cause) {
66
+ await discard(temp);
67
+ throw cause;
68
+ }
69
+ }
@@ -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): Promise<BuildResult>;
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 { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
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 writeFile(path.join(outDir, FRAME_INDEX_FILENAME), stableStringify(index), "utf8");
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 copyFile(previewSource, path.join(outDir, previewName));
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),
@@ -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
- let bytes = 0;
45
- zip.outputStream.on("data", (chunk) => {
46
- bytes += chunk.length;
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.`);
@@ -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. */
@@ -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.0";
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.0";
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 manifest targets a newer minor contract than this build.
11
- * The frame still loads; some fields may be ignored.
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");
@@ -15,6 +15,7 @@ export interface FrameToManifestOptions {
15
15
  name: string;
16
16
  url?: string;
17
17
  };
18
+ license?: string;
18
19
  /**
19
20
  * Maps a build-time asset URL to its packaged location.
20
21
  *
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardanvil/frame-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The contract Card Anvil frames are authored against: schemas, types, authoring helpers, and the distributable manifest format.",
5
5
  "license": "MIT",
6
6
  "type": "module",