@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.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/authoring/index.d.ts +2 -0
- package/dist/authoring/index.js +2 -0
- package/dist/authoring/layoutDefaults.d.ts +6 -0
- package/dist/authoring/layoutDefaults.js +80 -0
- package/dist/authoring/omit.d.ts +2 -0
- package/dist/authoring/omit.js +14 -0
- package/dist/cli/api.d.ts +34 -0
- package/dist/cli/api.js +188 -0
- package/dist/cli/build.d.ts +36 -0
- package/dist/cli/build.js +222 -0
- package/dist/cli/bundle.d.ts +23 -0
- package/dist/cli/bundle.js +58 -0
- package/dist/cli/devUrl.d.ts +10 -0
- package/dist/cli/devUrl.js +41 -0
- package/dist/cli/discover.d.ts +25 -0
- package/dist/cli/discover.js +116 -0
- package/dist/cli/emitSchemas.d.ts +2 -0
- package/dist/cli/emitSchemas.js +13 -0
- package/dist/cli/errors.d.ts +8 -0
- package/dist/cli/errors.js +11 -0
- package/dist/cli/frameIndex.d.ts +115 -0
- package/dist/cli/frameIndex.js +104 -0
- package/dist/cli/hash.d.ts +14 -0
- package/dist/cli/hash.js +32 -0
- package/dist/cli/imageSize.d.ts +13 -0
- package/dist/cli/imageSize.js +72 -0
- package/dist/cli/index.d.ts +18 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli/loadFrame.d.ts +24 -0
- package/dist/cli/loadFrame.js +90 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +228 -0
- package/dist/cli/report.d.ts +35 -0
- package/dist/cli/report.js +74 -0
- package/dist/cli/schema.d.ts +33 -0
- package/dist/cli/schema.js +51 -0
- package/dist/cli/stableJson.d.ts +11 -0
- package/dist/cli/stableJson.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/manifest/contract.d.ts +10503 -0
- package/dist/manifest/contract.js +87 -0
- package/dist/manifest/deserialize.d.ts +19 -0
- package/dist/manifest/deserialize.js +23 -0
- package/dist/manifest/index.d.ts +4 -0
- package/dist/manifest/index.js +4 -0
- package/dist/manifest/serialize.d.ts +35 -0
- package/dist/manifest/serialize.js +44 -0
- package/dist/manifest/testFrame.d.ts +9 -0
- package/dist/manifest/testFrame.js +53 -0
- package/dist/manifest/walk.d.ts +21 -0
- package/dist/manifest/walk.js +111 -0
- package/dist/schema/assetUrl.d.ts +27 -0
- package/dist/schema/assetUrl.js +28 -0
- package/dist/schema/frame.d.ts +26467 -0
- package/dist/schema/frame.js +312 -0
- package/dist/schema/frameAssets.d.ts +539 -0
- package/dist/schema/frameAssets.js +183 -0
- package/dist/schema/frameMeta.d.ts +24 -0
- package/dist/schema/frameMeta.js +48 -0
- package/dist/schema/index.d.ts +5 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/layout.d.ts +101 -0
- package/dist/schema/layout.js +59 -0
- package/package.json +62 -0
- package/schema/frame-index.schema.json +233 -0
- 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,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,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>;
|
package/dist/cli/api.js
ADDED
|
@@ -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>;
|