@cardanvil/frame-kit 0.2.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/dist/authoring/index.d.ts +2 -0
  4. package/dist/authoring/index.js +2 -0
  5. package/dist/authoring/layoutDefaults.d.ts +6 -0
  6. package/dist/authoring/layoutDefaults.js +80 -0
  7. package/dist/authoring/omit.d.ts +2 -0
  8. package/dist/authoring/omit.js +14 -0
  9. package/dist/cli/api.d.ts +34 -0
  10. package/dist/cli/api.js +188 -0
  11. package/dist/cli/build.d.ts +36 -0
  12. package/dist/cli/build.js +222 -0
  13. package/dist/cli/bundle.d.ts +23 -0
  14. package/dist/cli/bundle.js +58 -0
  15. package/dist/cli/devUrl.d.ts +10 -0
  16. package/dist/cli/devUrl.js +41 -0
  17. package/dist/cli/discover.d.ts +25 -0
  18. package/dist/cli/discover.js +116 -0
  19. package/dist/cli/emitSchemas.d.ts +2 -0
  20. package/dist/cli/emitSchemas.js +13 -0
  21. package/dist/cli/errors.d.ts +8 -0
  22. package/dist/cli/errors.js +11 -0
  23. package/dist/cli/frameIndex.d.ts +115 -0
  24. package/dist/cli/frameIndex.js +104 -0
  25. package/dist/cli/hash.d.ts +14 -0
  26. package/dist/cli/hash.js +32 -0
  27. package/dist/cli/imageSize.d.ts +13 -0
  28. package/dist/cli/imageSize.js +72 -0
  29. package/dist/cli/index.d.ts +18 -0
  30. package/dist/cli/index.js +18 -0
  31. package/dist/cli/loadFrame.d.ts +24 -0
  32. package/dist/cli/loadFrame.js +90 -0
  33. package/dist/cli/main.d.ts +2 -0
  34. package/dist/cli/main.js +228 -0
  35. package/dist/cli/report.d.ts +35 -0
  36. package/dist/cli/report.js +74 -0
  37. package/dist/cli/schema.d.ts +33 -0
  38. package/dist/cli/schema.js +51 -0
  39. package/dist/cli/stableJson.d.ts +11 -0
  40. package/dist/cli/stableJson.js +27 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +2 -0
  43. package/dist/manifest/contract.d.ts +10503 -0
  44. package/dist/manifest/contract.js +87 -0
  45. package/dist/manifest/deserialize.d.ts +19 -0
  46. package/dist/manifest/deserialize.js +23 -0
  47. package/dist/manifest/index.d.ts +4 -0
  48. package/dist/manifest/index.js +4 -0
  49. package/dist/manifest/serialize.d.ts +35 -0
  50. package/dist/manifest/serialize.js +44 -0
  51. package/dist/manifest/testFrame.d.ts +9 -0
  52. package/dist/manifest/testFrame.js +53 -0
  53. package/dist/manifest/walk.d.ts +21 -0
  54. package/dist/manifest/walk.js +111 -0
  55. package/dist/schema/assetUrl.d.ts +27 -0
  56. package/dist/schema/assetUrl.js +28 -0
  57. package/dist/schema/frame.d.ts +26467 -0
  58. package/dist/schema/frame.js +312 -0
  59. package/dist/schema/frameAssets.d.ts +539 -0
  60. package/dist/schema/frameAssets.js +183 -0
  61. package/dist/schema/frameMeta.d.ts +24 -0
  62. package/dist/schema/frameMeta.js +48 -0
  63. package/dist/schema/index.d.ts +5 -0
  64. package/dist/schema/index.js +5 -0
  65. package/dist/schema/layout.d.ts +101 -0
  66. package/dist/schema/layout.js +59 -0
  67. package/package.json +62 -0
  68. package/schema/frame-index.schema.json +233 -0
  69. package/schema/frame-meta.schema.json +54 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Taxiera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # @cardanvil/frame-kit
2
+
3
+ The contract [Card Anvil](https://github.com/Card-Anvil) frames are authored against: the schemas that
4
+ define what a frame is, the helpers you author one with, and the format frames are distributed in.
5
+
6
+ ```sh
7
+ pnpm add -D @cardanvil/frame-kit zod
8
+ ```
9
+
10
+ `zod` is a peer dependency — the frame types are inferred from zod schemas, so you and this package
11
+ must share one copy.
12
+
13
+ > **Building a frame?** Start from
14
+ > [Card-Anvil/frame-template](https://github.com/Card-Anvil/frame-template) instead of wiring this up
15
+ > by hand. This package is the contract underneath it.
16
+
17
+ ## A frame is data
18
+
19
+ A frame is one plain object: box geometry, asset URLs, and a few render knobs. Nothing in it runs at
20
+ render time. That constraint is the whole reason frames are distributable — a frame can be serialized
21
+ to a `frame.json` plus a folder of images and loaded by an app that never compiled it, precisely
22
+ because there is no behaviour to carry across.
23
+
24
+ ```ts
25
+ import { type Frame, withCollectorInfoDefaults } from "@cardanvil/frame-kit";
26
+
27
+ import preview from "./preview.jpg";
28
+ import { regularLayoutConfig } from "./regular/config";
29
+
30
+ export const myFrame = {
31
+ name: "Parchment",
32
+ description: "A hand-inked parchment frame.",
33
+ previewImage: preview,
34
+ tags: ["Custom"],
35
+ config: {
36
+ layouts: {
37
+ normal: withCollectorInfoDefaults(regularLayoutConfig, collectorBounds),
38
+ },
39
+ },
40
+ } as const satisfies Frame;
41
+ ```
42
+
43
+ `as const satisfies Frame` is the idiom: `satisfies` checks the object against the contract, `as const`
44
+ keeps the literal types.
45
+
46
+ ## Entry points
47
+
48
+ | Import | What it gives you |
49
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
50
+ | `@cardanvil/frame-kit` | The schemas (`FrameSchema`, `LayoutConfigSchema`, `FrameAssetsSchema`, …), the inferred types (`Frame`, `LayoutConfig`, `CardBoxes`, `TextBox`, `Layout`), and the authoring helpers |
51
+ | `@cardanvil/frame-kit/manifest` | The distributable format: `frameToManifest`, `manifestToFrame`, `walkAssets`, `FrameManifestSchema`, `isContractCompatible`, `assertDeclarative` |
52
+ | `@cardanvil/frame-kit/packaging` | The packaging tools the CLI is built on: `validateFrames`, `buildFrames`, `FrameIndexSchema`. Needs Node and Vite, so it is deliberately not on the main entry point |
53
+
54
+ ## The `frame-kit` command
55
+
56
+ ```bash
57
+ frame-kit validate # check every frame, write nothing
58
+ frame-kit build --out dist # check, then pack each one
59
+ frame-kit schema meta # a format as JSON Schema
60
+ frame-kit --help
61
+ ```
62
+
63
+ A frame is any directory holding a `frame.meta.json`, so this works both in a
64
+ workspace of packages and in a flat repository.
65
+
66
+ `validate` loads every frame through Vite, parses it against `FrameSchema`,
67
+ resolves every asset to a file that exists, and round-trips it through the
68
+ manifest in memory. Problems are collected rather than thrown, so one run
69
+ reports everything; under GitHub Actions they become inline annotations.
70
+
71
+ `build` adds hashing, zipping and the release index. Each frame becomes a
72
+ `<slug>-<version>.cardframe`, and `frame-index.json` describes the set.
73
+
74
+ Exit codes: `0` clean, `1` one or more frames failed, `2` used wrongly.
75
+
76
+ Packaging needs Vite — it is what turns `import w from "./w.png"` into a file —
77
+ so it is an **optional** peer dependency, loaded only when packaging runs. The
78
+ schemas above work under plain Node and in a browser without it.
79
+
80
+ ## Type it, then validate it
81
+
82
+ Typing a config as `Frame` is weaker than the schema a loading app enforces. The clearest example:
83
+ passing a whole asset barrel to a typed field type-checks even when the barrel exports extra keys,
84
+ because excess property checks do not apply to namespace objects — and those extra keys then enter the
85
+ frame contract where nothing can resolve them.
86
+
87
+ ```ts
88
+ import { FrameSchema } from "@cardanvil/frame-kit";
89
+
90
+ const result = FrameSchema.safeParse(myFrame);
91
+ expect(result.error?.issues ?? []).toEqual([]);
92
+ ```
93
+
94
+ ## The coordinate space
95
+
96
+ Boxes are **absolute pixels** on a **3264 × 4440** canvas.
97
+
98
+ ⚠ **A Magic card face is 63 × 88 mm** — _not_ 2.5 × 3.5 inches, which is the poker-card size and is
99
+ wrong by 0.5 mm across and 0.9 mm tall, roughly 24 × 43 pixels at 1200 DPI. In inches the card is
100
+ 2.48 × 3.46.
101
+
102
+ The canvas is **not** the card face: it is the full printed sheet **including bleed** — 2.72 × 3.7 in
103
+ (69.09 × 93.98 mm) at 1200 DPI, about 3 mm of bleed per edge. A box at `y: 0` sits in the bleed, above
104
+ the top of the card. Override the canvas per frame with `config.canvas`.
105
+
106
+ `fontSize` is in **points**, not pixels; the renderer converts.
107
+
108
+ ## The distributable format
109
+
110
+ `@cardanvil/frame-kit/manifest` converts a `Frame` into something an app can install: a `frame.json`
111
+ with every asset URL rewritten to a package-relative path, plus the files themselves.
112
+
113
+ Asset positions are found by **walking the schema**, not from a hard-coded list of field paths, so a
114
+ new asset field in the contract is picked up automatically.
115
+
116
+ ```ts
117
+ import { frameToManifest } from "@cardanvil/frame-kit/manifest";
118
+
119
+ const manifest = frameToManifest(myFrame, {
120
+ id: "com.example.parchment",
121
+ version: "1.0.0",
122
+ resolveAsset: (url) => ({ path: `assets/${hashOf(url)}.png` }),
123
+ });
124
+ ```
125
+
126
+ `manifestToFrame` reverses it, turning package-relative paths back into URLs the renderer can load —
127
+ an object URL for a zip, an `asset://` URL for a directory on disk, an absolute URL for a remote
128
+ package.
129
+
130
+ A manifest carries a `contractVersion` of the form `MAJOR.MINOR`, independent of this package's
131
+ 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.
134
+
135
+ ## Licence
136
+
137
+ MIT. See [LICENSE](https://github.com/Card-Anvil/frames/blob/trunk/LICENSE).
@@ -0,0 +1,2 @@
1
+ export * from "./layoutDefaults.js";
2
+ export * from "./omit.js";
@@ -0,0 +1,2 @@
1
+ export * from "./layoutDefaults.js";
2
+ export * from "./omit.js";
@@ -0,0 +1,6 @@
1
+ import { LayoutConfig, TemplateSettingsConfig, TextBox } from "../schema/frame.js";
2
+ /**
3
+ * Applies default collector info bounds to a layout config if not specified.
4
+ */
5
+ export declare function withCollectorInfoDefaults<T extends LayoutConfig>(config: T, defaultCollectorInfoBounds: TextBox): T;
6
+ export declare function withSettingsDefaults<T extends LayoutConfig>(config: T, defaultSettings: TemplateSettingsConfig): T;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Applies default collector info bounds to a layout config if not specified.
3
+ */
4
+ export function withCollectorInfoDefaults(config, defaultCollectorInfoBounds) {
5
+ return {
6
+ ...config,
7
+ boxes: {
8
+ ...config.boxes,
9
+ collectorInfo: config.boxes.collectorInfo ?? defaultCollectorInfoBounds,
10
+ },
11
+ tallBoxes: config.tallBoxes
12
+ ? {
13
+ ...config.tallBoxes,
14
+ collectorInfo: config.tallBoxes.collectorInfo ?? defaultCollectorInfoBounds,
15
+ }
16
+ : undefined,
17
+ backBoxes: config.backBoxes
18
+ ? {
19
+ ...config.backBoxes,
20
+ collectorInfo: config.backBoxes.collectorInfo ?? defaultCollectorInfoBounds,
21
+ }
22
+ : undefined,
23
+ sagaFrontBoxes: config.sagaFrontBoxes
24
+ ? {
25
+ ...config.sagaFrontBoxes,
26
+ collectorInfo: config.sagaFrontBoxes.collectorInfo ?? defaultCollectorInfoBounds,
27
+ }
28
+ : undefined,
29
+ sagaBackBoxes: config.sagaBackBoxes
30
+ ? {
31
+ ...config.sagaBackBoxes,
32
+ collectorInfo: config.sagaBackBoxes.collectorInfo ?? defaultCollectorInfoBounds,
33
+ }
34
+ : undefined,
35
+ creatureBoxes: config.creatureBoxes
36
+ ? {
37
+ ...config.creatureBoxes,
38
+ collectorInfo: config.creatureBoxes.collectorInfo ?? defaultCollectorInfoBounds,
39
+ }
40
+ : undefined,
41
+ backCreatureBoxes: config.backCreatureBoxes
42
+ ? {
43
+ ...config.backCreatureBoxes,
44
+ collectorInfo: config.backCreatureBoxes.collectorInfo ??
45
+ defaultCollectorInfoBounds,
46
+ }
47
+ : undefined,
48
+ sagaFrontCreatureBoxes: config.sagaFrontCreatureBoxes
49
+ ? {
50
+ ...config.sagaFrontCreatureBoxes,
51
+ collectorInfo: config.sagaFrontCreatureBoxes.collectorInfo ??
52
+ defaultCollectorInfoBounds,
53
+ }
54
+ : undefined,
55
+ sagaBackCreatureBoxes: config.sagaBackCreatureBoxes
56
+ ? {
57
+ ...config.sagaBackCreatureBoxes,
58
+ collectorInfo: config.sagaBackCreatureBoxes.collectorInfo ??
59
+ defaultCollectorInfoBounds,
60
+ }
61
+ : undefined,
62
+ // Saga layouts swap `boxes` for `flipsidePtBoxes` on face 0 when the
63
+ // other face has power/toughness (see renderCardToCanvas) — without this,
64
+ // that swap silently drops collector info whenever it triggers.
65
+ flipsidePtBoxes: config.flipsidePtBoxes
66
+ ? {
67
+ ...config.flipsidePtBoxes,
68
+ collectorInfo: config.flipsidePtBoxes.collectorInfo ?? defaultCollectorInfoBounds,
69
+ }
70
+ : undefined,
71
+ };
72
+ }
73
+ export function withSettingsDefaults(config, defaultSettings) {
74
+ const layoutSettings = config.templateSettings ?? {};
75
+ const mergedSettings = { ...defaultSettings, ...layoutSettings };
76
+ return {
77
+ ...config,
78
+ templateSettings: mergedSettings,
79
+ };
80
+ }
@@ -0,0 +1,2 @@
1
+ /** Returns a copy of `obj` with the given keys removed (non-mutating). */
2
+ export declare function omit<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Omit<T, K>;
@@ -0,0 +1,14 @@
1
+ /** Returns a copy of `obj` with the given keys removed (non-mutating). */
2
+ export function omit(obj, keys) {
3
+ const keyMap = {};
4
+ for (const key of keys) {
5
+ keyMap[key] = true;
6
+ }
7
+ const result = {};
8
+ for (const [k, v] of Object.entries(obj)) {
9
+ if (!(k in keyMap)) {
10
+ result[k] = v;
11
+ }
12
+ }
13
+ return result;
14
+ }
@@ -0,0 +1,34 @@
1
+ import { type Frame } from "../schema/frame.js";
2
+ import { type DiscoveredFrame } from "./discover.js";
3
+ import { Reporter } from "./report.js";
4
+ export interface LoadedFrame {
5
+ discovered: DiscoveredFrame;
6
+ /** The frame as authored, validated against `FrameSchema`. */
7
+ frame: Frame;
8
+ /** JSON projection — asset sets are module namespace objects when authored. */
9
+ plain: unknown;
10
+ /** Every asset in the frame, resolved to a file that exists. */
11
+ assets: {
12
+ url: string;
13
+ file: string;
14
+ }[];
15
+ }
16
+ export interface LoadOptions {
17
+ root: string;
18
+ /** Restrict to these slugs. Empty means every frame found. */
19
+ only?: readonly string[];
20
+ }
21
+ /**
22
+ * Loads, validates and resolves every frame under `root`.
23
+ *
24
+ * Shared by `validate` and `build` so the two can never disagree about whether
25
+ * a frame is sound.
26
+ */
27
+ export declare function loadFrames(options: LoadOptions, reporter: Reporter): Promise<LoadedFrame[]>;
28
+ export interface ValidateResult {
29
+ reporter: Reporter;
30
+ /** The frames that loaded cleanly. Fewer than were found, if some failed. */
31
+ frames: LoadedFrame[];
32
+ }
33
+ /** Loads every frame and reports what is wrong. Writes nothing. */
34
+ export declare function validateFrames(options: LoadOptions): Promise<ValidateResult>;
@@ -0,0 +1,188 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { FrameManifestSchema, frameToManifest, manifestToFrame, walkAssets, } from "../manifest/index.js";
4
+ import { FrameSchema } from "../schema/frame.js";
5
+ import { devUrlToFile } from "./devUrl.js";
6
+ import { discoverFrames, relativePosix, } from "./discover.js";
7
+ import { PackagingError } from "./errors.js";
8
+ import { loadFrameExport, startFrameServer, } from "./loadFrame.js";
9
+ import { Reporter } from "./report.js";
10
+ /** Namespace reserved for Card Anvil's own frames. */
11
+ const RESERVED_ID_PREFIX = "com.cardanvil.";
12
+ /** Deep JSON copy: frame configs wire module namespaces into asset fields. */
13
+ function toPlain(frame) {
14
+ return JSON.parse(JSON.stringify(frame));
15
+ }
16
+ /**
17
+ * Loads, validates and resolves every frame under `root`.
18
+ *
19
+ * Shared by `validate` and `build` so the two can never disagree about whether
20
+ * a frame is sound.
21
+ */
22
+ export async function loadFrames(options, reporter) {
23
+ const root = path.resolve(options.root);
24
+ const discovered = await discoverFrames(root, reporter);
25
+ const only = new Set(options.only ?? []);
26
+ const wanted = only.size === 0
27
+ ? discovered
28
+ : discovered.filter((frame) => only.has(frame.slug));
29
+ for (const slug of only) {
30
+ if (!discovered.some((frame) => frame.slug === slug)) {
31
+ reporter.error({ message: `no frame named "${slug}" was found` });
32
+ }
33
+ }
34
+ if (wanted.length === 0) {
35
+ if (discovered.length === 0) {
36
+ reporter.error({
37
+ message: `no frames found under ${root}. A frame is any directory with a ` +
38
+ `frame.meta.json in it.`,
39
+ });
40
+ }
41
+ return [];
42
+ }
43
+ let frameServer;
44
+ const loaded = [];
45
+ try {
46
+ frameServer = await startFrameServer(root);
47
+ for (const frame of wanted) {
48
+ const result = await loadOne(frameServer, root, frame, reporter);
49
+ if (result) {
50
+ loaded.push(result);
51
+ }
52
+ }
53
+ }
54
+ finally {
55
+ await frameServer?.close();
56
+ }
57
+ return loaded;
58
+ }
59
+ async function loadOne(frameServer, root, discovered, reporter) {
60
+ const slug = discovered.slug;
61
+ const file = relativePosix(root, discovered.metaFile);
62
+ const owner = process.env.GITHUB_REPOSITORY_OWNER;
63
+ if (discovered.meta.id.startsWith(RESERVED_ID_PREFIX) &&
64
+ owner !== undefined &&
65
+ owner !== "Card-Anvil") {
66
+ reporter.warn({
67
+ frame: slug,
68
+ file,
69
+ message: `id "${discovered.meta.id}" is in Card Anvil's own namespace. An id is ` +
70
+ `permanent and a marketplace binds it to a repository — use your own, ` +
71
+ `e.g. com.github.${owner}.${slug}.`,
72
+ });
73
+ }
74
+ let exported;
75
+ try {
76
+ exported = await loadFrameExport(frameServer, discovered.entryFile, discovered.meta.export);
77
+ }
78
+ catch (cause) {
79
+ reporter.error({
80
+ frame: slug,
81
+ file,
82
+ message: cause instanceof PackagingError ? cause.message : String(cause),
83
+ });
84
+ return undefined;
85
+ }
86
+ const parsed = FrameSchema.safeParse(exported);
87
+ if (!parsed.success) {
88
+ for (const issue of parsed.error.issues) {
89
+ reporter.error({
90
+ frame: slug,
91
+ file: relativePosix(root, discovered.entryFile),
92
+ path: issue.path.join(".") || undefined,
93
+ message: issue.message,
94
+ });
95
+ }
96
+ return undefined;
97
+ }
98
+ const plain = toPlain(parsed.data);
99
+ const assets = await resolveAssets(frameServer, root, slug, plain, reporter);
100
+ if (!assets) {
101
+ return undefined;
102
+ }
103
+ if (!roundTrips(slug, plain, assets, reporter)) {
104
+ return undefined;
105
+ }
106
+ return { discovered, frame: parsed.data, plain, assets };
107
+ }
108
+ /** Maps every asset URL to a file, and proves each one exists. */
109
+ async function resolveAssets(frameServer, root, slug, plain, reporter) {
110
+ const occurrences = walkAssets(FrameSchema, plain);
111
+ const byUrl = new Map();
112
+ let failed = false;
113
+ for (const occurrence of occurrences) {
114
+ if (byUrl.has(occurrence.url)) {
115
+ continue;
116
+ }
117
+ let file;
118
+ try {
119
+ file = devUrlToFile(occurrence.url, frameServer.root, frameServer.base);
120
+ }
121
+ catch (cause) {
122
+ reporter.error({
123
+ frame: slug,
124
+ path: occurrence.path.join("."),
125
+ message: cause instanceof PackagingError ? cause.message : String(cause),
126
+ });
127
+ failed = true;
128
+ continue;
129
+ }
130
+ try {
131
+ const stats = await stat(file);
132
+ if (!stats.isFile()) {
133
+ throw new Error("not a file");
134
+ }
135
+ }
136
+ catch {
137
+ reporter.error({
138
+ frame: slug,
139
+ path: occurrence.path.join("."),
140
+ message: `file not found: ${relativePosix(root, file)}`,
141
+ });
142
+ failed = true;
143
+ continue;
144
+ }
145
+ byUrl.set(occurrence.url, file);
146
+ }
147
+ return failed ? undefined : [...byUrl].map(([url, file]) => ({ url, file }));
148
+ }
149
+ /**
150
+ * Packs and unpacks the frame in memory, proving the bundle it would produce is
151
+ * one a loader could actually read back. `build` repeats this on the real
152
+ * manifest, so a build never emits an unloadable bundle.
153
+ */
154
+ function roundTrips(slug, plain, assets, reporter) {
155
+ const paths = new Map(assets.map(({ url, file }, index) => [
156
+ url,
157
+ `assets/${String(index)}${path.extname(file).toLowerCase()}`,
158
+ ]));
159
+ try {
160
+ const manifest = frameToManifest(plain, {
161
+ id: "com.example.check",
162
+ version: "0.0.0",
163
+ resolveAsset: (url) => {
164
+ const packaged = paths.get(url);
165
+ if (packaged === undefined) {
166
+ throw new PackagingError(`unresolved asset ${url}`);
167
+ }
168
+ return { path: packaged };
169
+ },
170
+ });
171
+ FrameManifestSchema.parse(JSON.parse(JSON.stringify(manifest)));
172
+ manifestToFrame(manifest, { resolveUrl: (packaged) => packaged });
173
+ return true;
174
+ }
175
+ catch (cause) {
176
+ reporter.error({
177
+ frame: slug,
178
+ message: `cannot be packaged: ${cause instanceof Error ? cause.message : String(cause)}`,
179
+ });
180
+ return false;
181
+ }
182
+ }
183
+ /** Loads every frame and reports what is wrong. Writes nothing. */
184
+ export async function validateFrames(options) {
185
+ const reporter = new Reporter();
186
+ const frames = await loadFrames(options, reporter);
187
+ return { reporter, frames };
188
+ }
@@ -0,0 +1,36 @@
1
+ import { type FrameManifest } from "../manifest/index.js";
2
+ import { type LoadOptions, type LoadedFrame } from "./api.js";
3
+ import { type FrameIndex, type IndexedFrame, type ReleaseSource } from "./frameIndex.js";
4
+ import type { Reporter } from "./report.js";
5
+ export interface BuildOptions extends LoadOptions {
6
+ /** Directory bundles and the index are written to. Created if missing. */
7
+ outDir: string;
8
+ /** Stamped into every manifest. One version for every frame in the repo. */
9
+ version: string;
10
+ generator?: string;
11
+ /** Where the release will live. Omitted for a local build. */
12
+ source?: ReleaseSource;
13
+ }
14
+ export interface BuiltFrame {
15
+ loaded: LoadedFrame;
16
+ manifest: FrameManifest;
17
+ /** Absolute path to the written bundle. */
18
+ bundleFile: string;
19
+ /** Filename only, which is what a release asset is addressed by. */
20
+ bundleName: string;
21
+ bytes: number;
22
+ /** This frame's row in the release index. */
23
+ entry: IndexedFrame;
24
+ }
25
+ export interface BuildResult {
26
+ frames: BuiltFrame[];
27
+ /** Written to `<out>/frame-index.json` unless nothing built. */
28
+ index?: FrameIndex;
29
+ }
30
+ /**
31
+ * Packs every sound frame into a `.cardframe` bundle.
32
+ *
33
+ * Frames that fail to load are reported and skipped rather than aborting the
34
+ * run, so one bad frame does not hide the state of the others.
35
+ */
36
+ export declare function buildFrames(options: BuildOptions, reporter: Reporter): Promise<BuildResult>;