@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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { FrameManifestSchema, frameToManifest, manifestToFrame, } from "../manifest/index.js";
|
|
4
|
+
import { DEFAULT_CANVAS_HEIGHT, DEFAULT_CANVAS_WIDTH, } from "../schema/frame.js";
|
|
5
|
+
import { loadFrames } from "./api.js";
|
|
6
|
+
import { describeBytes, writeBundle } from "./bundle.js";
|
|
7
|
+
import { PackagingError } from "./errors.js";
|
|
8
|
+
import { FRAME_INDEX_FILENAME, buildIndex, downloadBase, } from "./frameIndex.js";
|
|
9
|
+
import { hashFile, mapWithConcurrency } from "./hash.js";
|
|
10
|
+
import { readImageSize } from "./imageSize.js";
|
|
11
|
+
import { stableStringify } from "./stableJson.js";
|
|
12
|
+
/** How many characters of the sha256 name a packaged asset. 64 bits. */
|
|
13
|
+
const HASH_PREFIX_LENGTH = 16;
|
|
14
|
+
/** GitHub refuses individual repository files above 100 MB. */
|
|
15
|
+
const MAX_ASSET_BYTES = 100 * 1024 * 1024;
|
|
16
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
17
|
+
/**
|
|
18
|
+
* Packs every sound frame into a `.cardframe` bundle.
|
|
19
|
+
*
|
|
20
|
+
* Frames that fail to load are reported and skipped rather than aborting the
|
|
21
|
+
* run, so one bad frame does not hide the state of the others.
|
|
22
|
+
*/
|
|
23
|
+
export async function buildFrames(options, reporter) {
|
|
24
|
+
if (!VERSION_PATTERN.test(options.version)) {
|
|
25
|
+
throw new PackagingError(`"${options.version}" is not a version. Expected MAJOR.MINOR.PATCH, ` +
|
|
26
|
+
`optionally with a pre-release suffix.`);
|
|
27
|
+
}
|
|
28
|
+
const loaded = await loadFrames(options, reporter);
|
|
29
|
+
const outDir = path.resolve(options.outDir);
|
|
30
|
+
await mkdir(outDir, { recursive: true });
|
|
31
|
+
const built = [];
|
|
32
|
+
for (const frame of loaded) {
|
|
33
|
+
const result = await buildOne(frame, outDir, options, reporter);
|
|
34
|
+
if (result) {
|
|
35
|
+
built.push(result);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (built.length === 0) {
|
|
39
|
+
return { frames: built };
|
|
40
|
+
}
|
|
41
|
+
const index = buildIndex({
|
|
42
|
+
version: options.version,
|
|
43
|
+
...(options.generator === undefined
|
|
44
|
+
? {}
|
|
45
|
+
: { generator: options.generator }),
|
|
46
|
+
...(options.source === undefined ? {} : { source: options.source }),
|
|
47
|
+
frames: built.map((frame) => frame.entry),
|
|
48
|
+
});
|
|
49
|
+
await writeFile(path.join(outDir, FRAME_INDEX_FILENAME), stableStringify(index), "utf8");
|
|
50
|
+
return { frames: built, index };
|
|
51
|
+
}
|
|
52
|
+
async function buildOne(loaded, outDir, options, reporter) {
|
|
53
|
+
const slug = loaded.discovered.slug;
|
|
54
|
+
const packaged = await packageAssets(loaded, slug, reporter);
|
|
55
|
+
if (!packaged) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const { byUrl, fileByPath } = packaged;
|
|
59
|
+
let manifest;
|
|
60
|
+
try {
|
|
61
|
+
manifest = frameToManifest(loaded.plain, {
|
|
62
|
+
id: loaded.discovered.meta.id,
|
|
63
|
+
version: options.version,
|
|
64
|
+
...(options.generator === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { generator: options.generator }),
|
|
67
|
+
author: loaded.discovered.meta.author,
|
|
68
|
+
resolveAsset: (url) => {
|
|
69
|
+
const asset = byUrl.get(url);
|
|
70
|
+
if (asset === undefined) {
|
|
71
|
+
throw new PackagingError(`unresolved asset ${url}`);
|
|
72
|
+
}
|
|
73
|
+
return asset;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
// Prove the bundle is one a loader could read back — on the real manifest,
|
|
77
|
+
// through JSON, so anything non-serializable is caught too.
|
|
78
|
+
const roundTripped = JSON.parse(stableStringify(manifest));
|
|
79
|
+
FrameManifestSchema.parse(roundTripped);
|
|
80
|
+
manifestToFrame(roundTripped, { resolveUrl: (assetPath) => assetPath });
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
reporter.error({
|
|
84
|
+
frame: slug,
|
|
85
|
+
message: `cannot be packaged: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
86
|
+
});
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const bundleName = `${slug}-${options.version}.cardframe`;
|
|
90
|
+
const bundleFile = path.join(outDir, bundleName);
|
|
91
|
+
try {
|
|
92
|
+
const { bytes, large } = await writeBundle(bundleFile, manifest, {
|
|
93
|
+
fileByPath,
|
|
94
|
+
});
|
|
95
|
+
if (large) {
|
|
96
|
+
reporter.warn({
|
|
97
|
+
frame: slug,
|
|
98
|
+
message: `${bundleName} is ${describeBytes(bytes)}. It will publish, but that ` +
|
|
99
|
+
`is a long download for someone trying the frame out.`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const entry = await indexEntry(loaded, manifest, { bundleName, bundleFile, bytes }, fileByPath, outDir, options);
|
|
103
|
+
return { loaded, manifest, bundleFile, bundleName, bytes, entry };
|
|
104
|
+
}
|
|
105
|
+
catch (cause) {
|
|
106
|
+
reporter.error({
|
|
107
|
+
frame: slug,
|
|
108
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
109
|
+
});
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Hashes every asset and decides where it lands in the bundle.
|
|
115
|
+
*
|
|
116
|
+
* Runs as a pre-pass because `frameToManifest`'s `resolveAsset` is synchronous
|
|
117
|
+
* and hashing is not. Naming an asset after its content means deduplication
|
|
118
|
+
* needs no bookkeeping — two identical files simply claim the same path — and
|
|
119
|
+
* makes the output independent of traversal order.
|
|
120
|
+
*/
|
|
121
|
+
async function packageAssets(loaded, slug, reporter) {
|
|
122
|
+
const byUrl = new Map();
|
|
123
|
+
const byFile = new Map();
|
|
124
|
+
const fileByPath = new Map();
|
|
125
|
+
const hashByPath = new Map();
|
|
126
|
+
// A list rather than a boolean: the worker runs inside a callback, where a
|
|
127
|
+
// flag assignment does not survive TypeScript's narrowing.
|
|
128
|
+
const failures = [];
|
|
129
|
+
await mapWithConcurrency(loaded.assets, 8, async ({ url, file }) => {
|
|
130
|
+
const seen = byFile.get(file);
|
|
131
|
+
if (seen) {
|
|
132
|
+
byUrl.set(url, seen);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const { size } = await stat(file);
|
|
137
|
+
if (size > MAX_ASSET_BYTES) {
|
|
138
|
+
reporter.error({
|
|
139
|
+
frame: slug,
|
|
140
|
+
message: `${path.basename(file)} is ${describeBytes(size)}, over GitHub's ` +
|
|
141
|
+
`100 MB limit for a single file.`,
|
|
142
|
+
});
|
|
143
|
+
failures.push(file);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const sha256 = await hashFile(file);
|
|
147
|
+
const assetPath = `assets/${sha256.slice(0, HASH_PREFIX_LENGTH)}${path
|
|
148
|
+
.extname(file)
|
|
149
|
+
.toLowerCase()}`;
|
|
150
|
+
const clash = hashByPath.get(assetPath);
|
|
151
|
+
if (clash !== undefined && clash !== sha256) {
|
|
152
|
+
throw new PackagingError(`two different assets hash to ${assetPath}. This should not happen; ` +
|
|
153
|
+
`please report it.`);
|
|
154
|
+
}
|
|
155
|
+
hashByPath.set(assetPath, sha256);
|
|
156
|
+
const asset = { path: assetPath, bytes: size, sha256 };
|
|
157
|
+
byFile.set(file, asset);
|
|
158
|
+
byUrl.set(url, asset);
|
|
159
|
+
fileByPath.set(assetPath, file);
|
|
160
|
+
}
|
|
161
|
+
catch (cause) {
|
|
162
|
+
reporter.error({
|
|
163
|
+
frame: slug,
|
|
164
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
165
|
+
});
|
|
166
|
+
failures.push(file);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return failures.length > 0 ? undefined : { byUrl, fileByPath };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Builds one frame's row in the release index, and copies its preview out of
|
|
173
|
+
* the bundle so a browse UI can show the frame without downloading it.
|
|
174
|
+
*/
|
|
175
|
+
async function indexEntry(loaded, manifest, bundle, fileByPath, outDir, options) {
|
|
176
|
+
const { meta, slug } = loaded.discovered;
|
|
177
|
+
const previewSource = fileByPath.get(manifest.frame.previewImage);
|
|
178
|
+
if (previewSource === undefined) {
|
|
179
|
+
throw new PackagingError(`the preview image is not among the packaged assets — this is a bug in frame-kit`);
|
|
180
|
+
}
|
|
181
|
+
const previewName = `${slug}-${options.version}.preview${path.extname(previewSource).toLowerCase()}`;
|
|
182
|
+
await copyFile(previewSource, path.join(outDir, previewName));
|
|
183
|
+
const [previewHash, previewStats, size, bundleHash] = await Promise.all([
|
|
184
|
+
hashFile(previewSource),
|
|
185
|
+
stat(previewSource),
|
|
186
|
+
readImageSize(previewSource),
|
|
187
|
+
hashFile(bundle.bundleFile),
|
|
188
|
+
]);
|
|
189
|
+
const base = options.source === undefined ? undefined : downloadBase(options.source);
|
|
190
|
+
const withUrl = (artifact) => base === undefined
|
|
191
|
+
? artifact
|
|
192
|
+
: { ...artifact, url: `${base}${artifact.file}` };
|
|
193
|
+
return {
|
|
194
|
+
id: meta.id,
|
|
195
|
+
slug,
|
|
196
|
+
name: manifest.frame.name,
|
|
197
|
+
description: manifest.frame.description,
|
|
198
|
+
version: options.version,
|
|
199
|
+
tags: [...manifest.frame.tags],
|
|
200
|
+
layouts: Object.keys(manifest.frame.config.layouts).sort(),
|
|
201
|
+
canvas: manifest.frame.config.canvas ?? {
|
|
202
|
+
width: DEFAULT_CANVAS_WIDTH,
|
|
203
|
+
height: DEFAULT_CANVAS_HEIGHT,
|
|
204
|
+
},
|
|
205
|
+
author: meta.author,
|
|
206
|
+
license: meta.license,
|
|
207
|
+
...(meta.homepage === undefined ? {} : { homepage: meta.homepage }),
|
|
208
|
+
contractVersion: manifest.contractVersion,
|
|
209
|
+
assetCount: manifest.assets.length,
|
|
210
|
+
bundle: withUrl({
|
|
211
|
+
file: bundle.bundleName,
|
|
212
|
+
bytes: bundle.bytes,
|
|
213
|
+
sha256: bundleHash,
|
|
214
|
+
}),
|
|
215
|
+
preview: withUrl({
|
|
216
|
+
file: previewName,
|
|
217
|
+
bytes: previewStats.size,
|
|
218
|
+
sha256: previewHash,
|
|
219
|
+
...(size ?? {}),
|
|
220
|
+
}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FrameManifest } from "../manifest/contract.js";
|
|
2
|
+
export interface BundleSource {
|
|
3
|
+
/** Where the asset lives now, keyed by its path inside the bundle. */
|
|
4
|
+
fileByPath: ReadonlyMap<string, string>;
|
|
5
|
+
}
|
|
6
|
+
export interface BundleResult {
|
|
7
|
+
bytes: number;
|
|
8
|
+
/** True when the bundle is large enough to be worth mentioning. */
|
|
9
|
+
large: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Writes a `.cardframe`: the manifest, then every asset it names.
|
|
13
|
+
*
|
|
14
|
+
* Assets are **stored, not deflated**. PNG and JPEG are already compressed, so
|
|
15
|
+
* re-deflating hundreds of megabytes buys about a percent, costs minutes of
|
|
16
|
+
* CPU, and makes the output depend on the zlib version. Storing them keeps
|
|
17
|
+
* packing IO-bound and leaves the archive readable without inflating.
|
|
18
|
+
* `frame.json` is text and deflates roughly ten to one, so it stays compressed.
|
|
19
|
+
*
|
|
20
|
+
* Fixed timestamps and modes mean the same source produces the same bytes.
|
|
21
|
+
*/
|
|
22
|
+
export declare function writeBundle(outFile: string, manifest: FrameManifest, source: BundleSource): Promise<BundleResult>;
|
|
23
|
+
export declare function describeBytes(bytes: number): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
3
|
+
import yazl from "yazl";
|
|
4
|
+
import { PackagingError } from "./errors.js";
|
|
5
|
+
import { stableStringify } from "./stableJson.js";
|
|
6
|
+
/** The earliest instant a DOS timestamp can express. */
|
|
7
|
+
const ZIP_EPOCH = new Date(Date.UTC(1980, 0, 1));
|
|
8
|
+
/** GitHub refuses release assets above this. */
|
|
9
|
+
const MAX_BUNDLE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
10
|
+
/** Worth telling an author about long before they hit the hard limit. */
|
|
11
|
+
const WARN_BUNDLE_BYTES = 500 * 1024 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Writes a `.cardframe`: the manifest, then every asset it names.
|
|
14
|
+
*
|
|
15
|
+
* Assets are **stored, not deflated**. PNG and JPEG are already compressed, so
|
|
16
|
+
* re-deflating hundreds of megabytes buys about a percent, costs minutes of
|
|
17
|
+
* CPU, and makes the output depend on the zlib version. Storing them keeps
|
|
18
|
+
* packing IO-bound and leaves the archive readable without inflating.
|
|
19
|
+
* `frame.json` is text and deflates roughly ten to one, so it stays compressed.
|
|
20
|
+
*
|
|
21
|
+
* Fixed timestamps and modes mean the same source produces the same bytes.
|
|
22
|
+
*/
|
|
23
|
+
export async function writeBundle(outFile, manifest, source) {
|
|
24
|
+
const zip = new yazl.ZipFile();
|
|
25
|
+
zip.addBuffer(Buffer.from(stableStringify(manifest), "utf8"), "frame.json", {
|
|
26
|
+
mtime: ZIP_EPOCH,
|
|
27
|
+
mode: 0o100644,
|
|
28
|
+
compress: true,
|
|
29
|
+
});
|
|
30
|
+
// `manifest.assets` is already sorted by path, so entry order is stable.
|
|
31
|
+
for (const asset of manifest.assets) {
|
|
32
|
+
const file = source.fileByPath.get(asset.path);
|
|
33
|
+
if (file === undefined) {
|
|
34
|
+
throw new PackagingError(`no source file for bundle entry ${asset.path} — this is a bug in frame-kit`);
|
|
35
|
+
}
|
|
36
|
+
// yazl stats the file itself; `size` is only for stream entries.
|
|
37
|
+
zip.addFile(file, asset.path, {
|
|
38
|
+
mtime: ZIP_EPOCH,
|
|
39
|
+
mode: 0o100644,
|
|
40
|
+
compress: false,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
zip.end();
|
|
44
|
+
let bytes = 0;
|
|
45
|
+
zip.outputStream.on("data", (chunk) => {
|
|
46
|
+
bytes += chunk.length;
|
|
47
|
+
});
|
|
48
|
+
await pipeline(zip.outputStream, createWriteStream(outFile));
|
|
49
|
+
if (bytes > MAX_BUNDLE_BYTES) {
|
|
50
|
+
throw new PackagingError(`${outFile} is ${describeBytes(bytes)}, over GitHub's 2 GiB limit for a ` +
|
|
51
|
+
`release asset. Split the frame, or use smaller art.`);
|
|
52
|
+
}
|
|
53
|
+
return { bytes, large: bytes > WARN_BUNDLE_BYTES };
|
|
54
|
+
}
|
|
55
|
+
export function describeBytes(bytes) {
|
|
56
|
+
const mb = bytes / (1024 * 1024);
|
|
57
|
+
return mb >= 1024 ? `${(mb / 1024).toFixed(2)} GiB` : `${mb.toFixed(1)} MB`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverses Vite's `fileToDevUrl`, turning the URL a frame's asset import
|
|
3
|
+
* resolved to back into the file on disk it came from.
|
|
4
|
+
*
|
|
5
|
+
* In serve mode an asset under `root` becomes `base` + its POSIX path relative
|
|
6
|
+
* to root; anything outside root becomes `/@fs/` + its absolute path. The URL
|
|
7
|
+
* is built from the raw module id, so an import query rides along and has to be
|
|
8
|
+
* stripped.
|
|
9
|
+
*/
|
|
10
|
+
export declare function devUrlToFile(url: string, root: string, base: string): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { PackagingError } from "./errors.js";
|
|
3
|
+
/** Vite's prefix for files outside the project root. */
|
|
4
|
+
const FS_PREFIX = "/@fs/";
|
|
5
|
+
/** Matches a Windows drive letter at the start of a path. */
|
|
6
|
+
const DRIVE_LETTER = /^[a-zA-Z]:/;
|
|
7
|
+
/**
|
|
8
|
+
* Reverses Vite's `fileToDevUrl`, turning the URL a frame's asset import
|
|
9
|
+
* resolved to back into the file on disk it came from.
|
|
10
|
+
*
|
|
11
|
+
* In serve mode an asset under `root` becomes `base` + its POSIX path relative
|
|
12
|
+
* to root; anything outside root becomes `/@fs/` + its absolute path. The URL
|
|
13
|
+
* is built from the raw module id, so an import query rides along and has to be
|
|
14
|
+
* stripped.
|
|
15
|
+
*/
|
|
16
|
+
export function devUrlToFile(url, root, base) {
|
|
17
|
+
const pathname = url.split("?", 1)[0] ?? "";
|
|
18
|
+
if (pathname.startsWith("data:")) {
|
|
19
|
+
throw new PackagingError(`${url} is a data URL, not a file. The packager turns Vite's inline ` +
|
|
20
|
+
`limit off, so this is an explicit \`?inline\` import — drop the suffix ` +
|
|
21
|
+
`so the asset stays a real file.`);
|
|
22
|
+
}
|
|
23
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(pathname)) {
|
|
24
|
+
throw new PackagingError(`${url} points somewhere remote. A frame has to ship its own art, ` +
|
|
25
|
+
`because the bundle is what gets installed — remote URLs cannot be ` +
|
|
26
|
+
`packaged.`);
|
|
27
|
+
}
|
|
28
|
+
const decoded = decodeURIComponent(pathname);
|
|
29
|
+
if (decoded.startsWith(FS_PREFIX)) {
|
|
30
|
+
// `path.posix.join` built this, and it collapsed the double slash: a POSIX
|
|
31
|
+
// path lost its leading slash and needs it back, while a Windows path
|
|
32
|
+
// already carries its own drive letter.
|
|
33
|
+
const rest = decoded.slice(FS_PREFIX.length);
|
|
34
|
+
return path.resolve(DRIVE_LETTER.test(rest) ? rest : `/${rest}`);
|
|
35
|
+
}
|
|
36
|
+
if (!decoded.startsWith(base)) {
|
|
37
|
+
throw new PackagingError(`${url} is not under the dev base ${base}, so it cannot be mapped back ` +
|
|
38
|
+
`to a file.`);
|
|
39
|
+
}
|
|
40
|
+
return path.resolve(root, decoded.slice(base.length));
|
|
41
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FrameMeta } from "../schema/frameMeta.js";
|
|
2
|
+
import type { Reporter } from "./report.js";
|
|
3
|
+
export interface DiscoveredFrame {
|
|
4
|
+
/** Names the bundle file. From `meta.slug`, else the directory basename. */
|
|
5
|
+
slug: string;
|
|
6
|
+
/** Absolute directory holding the descriptor. */
|
|
7
|
+
dir: string;
|
|
8
|
+
/** Absolute path to the descriptor, for error messages. */
|
|
9
|
+
metaFile: string;
|
|
10
|
+
/** Absolute path to the module exporting the frame. */
|
|
11
|
+
entryFile: string;
|
|
12
|
+
meta: FrameMeta;
|
|
13
|
+
}
|
|
14
|
+
/** Path relative to `root` with POSIX separators, for messages and annotations. */
|
|
15
|
+
export declare function relativePosix(root: string, file: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Finds every frame under `root`.
|
|
18
|
+
*
|
|
19
|
+
* Marker-file driven rather than convention-guessed, because packaging has to
|
|
20
|
+
* work both in a workspace of packages and in a flat single-package repository.
|
|
21
|
+
*
|
|
22
|
+
* Every malformed descriptor and every collision is reported, not just the
|
|
23
|
+
* first — an author fixing their repo wants the whole list.
|
|
24
|
+
*/
|
|
25
|
+
export declare function discoverFrames(root: string, reporter: Reporter): Promise<DiscoveredFrame[]>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { FRAME_META_FILENAME, FrameMetaSchema, } from "../schema/frameMeta.js";
|
|
4
|
+
/** Directories never worth walking into when looking for frames. */
|
|
5
|
+
const SKIP = new Set([
|
|
6
|
+
"node_modules",
|
|
7
|
+
"dist",
|
|
8
|
+
".git",
|
|
9
|
+
".pnpm-store",
|
|
10
|
+
// The conventional name for test fixtures. A fixture frame exists to be
|
|
11
|
+
// packaged by a test, not to be shipped by whoever owns the repository.
|
|
12
|
+
"__fixtures__",
|
|
13
|
+
]);
|
|
14
|
+
/** Path relative to `root` with POSIX separators, for messages and annotations. */
|
|
15
|
+
export function relativePosix(root, file) {
|
|
16
|
+
return path.relative(root, file).split(path.sep).join("/");
|
|
17
|
+
}
|
|
18
|
+
async function findMetaFiles(dir, out) {
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return; // unreadable directory: nothing to discover, not worth failing over
|
|
25
|
+
}
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
if (!SKIP.has(entry.name) && !entry.name.startsWith(".")) {
|
|
29
|
+
await findMetaFiles(path.join(dir, entry.name), out);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else if (entry.name === FRAME_META_FILENAME) {
|
|
33
|
+
out.push(path.join(dir, entry.name));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Finds every frame under `root`.
|
|
39
|
+
*
|
|
40
|
+
* Marker-file driven rather than convention-guessed, because packaging has to
|
|
41
|
+
* work both in a workspace of packages and in a flat single-package repository.
|
|
42
|
+
*
|
|
43
|
+
* Every malformed descriptor and every collision is reported, not just the
|
|
44
|
+
* first — an author fixing their repo wants the whole list.
|
|
45
|
+
*/
|
|
46
|
+
export async function discoverFrames(root, reporter) {
|
|
47
|
+
const metaFiles = [];
|
|
48
|
+
await findMetaFiles(root, metaFiles);
|
|
49
|
+
metaFiles.sort((a, b) => a.localeCompare(b));
|
|
50
|
+
const frames = [];
|
|
51
|
+
for (const metaFile of metaFiles) {
|
|
52
|
+
const file = relativePosix(root, metaFile);
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(await readFile(metaFile, "utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch (cause) {
|
|
58
|
+
reporter.error({
|
|
59
|
+
file,
|
|
60
|
+
message: `is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const parsed = FrameMetaSchema.safeParse(raw);
|
|
65
|
+
if (!parsed.success) {
|
|
66
|
+
for (const issue of parsed.error.issues) {
|
|
67
|
+
reporter.error({
|
|
68
|
+
file,
|
|
69
|
+
path: issue.path.join(".") || undefined,
|
|
70
|
+
message: issue.message,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const dir = path.dirname(metaFile);
|
|
76
|
+
frames.push({
|
|
77
|
+
slug: parsed.data.slug ?? path.basename(dir),
|
|
78
|
+
dir,
|
|
79
|
+
metaFile,
|
|
80
|
+
entryFile: path.resolve(dir, parsed.data.entry),
|
|
81
|
+
meta: parsed.data,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
reportCollisions(root, frames, reporter);
|
|
85
|
+
return frames;
|
|
86
|
+
}
|
|
87
|
+
function reportCollisions(root, frames, reporter) {
|
|
88
|
+
for (const [key, label] of [
|
|
89
|
+
["id", "id"],
|
|
90
|
+
["slug", "slug"],
|
|
91
|
+
]) {
|
|
92
|
+
const seen = new Map();
|
|
93
|
+
for (const frame of frames) {
|
|
94
|
+
const value = key === "id" ? frame.meta.id : frame.slug;
|
|
95
|
+
seen.set(value, [...(seen.get(value) ?? []), frame]);
|
|
96
|
+
}
|
|
97
|
+
for (const [value, group] of seen) {
|
|
98
|
+
if (group.length < 2) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const where = group
|
|
102
|
+
.map((frame) => relativePosix(root, frame.metaFile))
|
|
103
|
+
.join(", ");
|
|
104
|
+
for (const frame of group) {
|
|
105
|
+
reporter.error({
|
|
106
|
+
frame: frame.slug,
|
|
107
|
+
file: relativePosix(root, frame.metaFile),
|
|
108
|
+
message: `${label} "${value}" is claimed by more than one frame (${where}). ` +
|
|
109
|
+
(label === "slug"
|
|
110
|
+
? 'Set a distinct "slug" — it names the bundle file.'
|
|
111
|
+
: "An id is permanent identity; two frames cannot share one."),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { SHIPPED_SCHEMAS, schemaFilename, toJsonSchema } from "./schema.js";
|
|
5
|
+
import { stableStringify } from "./stableJson.js";
|
|
6
|
+
/** Run at build time so the published package carries its own JSON Schema. */
|
|
7
|
+
const outDir = path.join(import.meta.dirname, "../../schema");
|
|
8
|
+
await mkdir(outDir, { recursive: true });
|
|
9
|
+
for (const name of SHIPPED_SCHEMAS) {
|
|
10
|
+
const file = path.join(outDir, schemaFilename(name));
|
|
11
|
+
await writeFile(file, stableStringify(toJsonSchema(name)), "utf8");
|
|
12
|
+
console.log(`schema: ${schemaFilename(name)}`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A problem the author can act on: a missing file, an unpackageable asset, a
|
|
3
|
+
* malformed descriptor. Distinct from a programming error so the CLI can print
|
|
4
|
+
* it plainly instead of dumping a stack.
|
|
5
|
+
*/
|
|
6
|
+
export declare class PackagingError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A problem the author can act on: a missing file, an unpackageable asset, a
|
|
3
|
+
* malformed descriptor. Distinct from a programming error so the CLI can print
|
|
4
|
+
* it plainly instead of dumping a stack.
|
|
5
|
+
*/
|
|
6
|
+
export class PackagingError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "PackagingError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Bumped only when the index's own shape changes, not the frame contract's. */
|
|
3
|
+
export declare const FRAME_INDEX_FORMAT_VERSION = 1;
|
|
4
|
+
export declare const FRAME_INDEX_FILENAME = "frame-index.json";
|
|
5
|
+
export declare const IndexedFrameSchema: z.ZodObject<{
|
|
6
|
+
id: z.ZodString;
|
|
7
|
+
slug: z.ZodString;
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
description: z.ZodString;
|
|
10
|
+
version: z.ZodString;
|
|
11
|
+
tags: z.ZodArray<z.ZodString>;
|
|
12
|
+
layouts: z.ZodArray<z.ZodString>;
|
|
13
|
+
canvas: z.ZodObject<{
|
|
14
|
+
width: z.ZodInt;
|
|
15
|
+
height: z.ZodInt;
|
|
16
|
+
}, z.core.$strip>;
|
|
17
|
+
author: z.ZodObject<{
|
|
18
|
+
name: z.ZodString;
|
|
19
|
+
url: z.ZodOptional<z.ZodString>;
|
|
20
|
+
}, z.core.$strip>;
|
|
21
|
+
license: z.ZodString;
|
|
22
|
+
homepage: z.ZodOptional<z.ZodString>;
|
|
23
|
+
contractVersion: z.ZodString;
|
|
24
|
+
assetCount: z.ZodInt;
|
|
25
|
+
bundle: z.ZodObject<{
|
|
26
|
+
file: z.ZodString;
|
|
27
|
+
url: z.ZodOptional<z.ZodURL>;
|
|
28
|
+
bytes: z.ZodInt;
|
|
29
|
+
sha256: z.ZodString;
|
|
30
|
+
}, z.core.$strip>;
|
|
31
|
+
preview: z.ZodObject<{
|
|
32
|
+
file: z.ZodString;
|
|
33
|
+
url: z.ZodOptional<z.ZodURL>;
|
|
34
|
+
bytes: z.ZodInt;
|
|
35
|
+
sha256: z.ZodString;
|
|
36
|
+
width: z.ZodOptional<z.ZodInt>;
|
|
37
|
+
height: z.ZodOptional<z.ZodInt>;
|
|
38
|
+
}, z.core.$strip>;
|
|
39
|
+
}, z.core.$strip>;
|
|
40
|
+
export type IndexedFrame = z.infer<typeof IndexedFrameSchema>;
|
|
41
|
+
export declare const FrameIndexSchema: z.ZodObject<{
|
|
42
|
+
app: z.ZodLiteral<"card-anvil">;
|
|
43
|
+
kind: z.ZodLiteral<"frame-index">;
|
|
44
|
+
formatVersion: z.ZodInt;
|
|
45
|
+
generatedAt: z.ZodString;
|
|
46
|
+
generator: z.ZodOptional<z.ZodString>;
|
|
47
|
+
contractVersion: z.ZodString;
|
|
48
|
+
version: z.ZodString;
|
|
49
|
+
source: z.ZodOptional<z.ZodObject<{
|
|
50
|
+
kind: z.ZodLiteral<"github-release">;
|
|
51
|
+
repository: z.ZodString;
|
|
52
|
+
tag: z.ZodString;
|
|
53
|
+
url: z.ZodURL;
|
|
54
|
+
downloadBase: z.ZodURL;
|
|
55
|
+
latestIndexUrl: z.ZodURL;
|
|
56
|
+
}, z.core.$strip>>;
|
|
57
|
+
frames: z.ZodArray<z.ZodObject<{
|
|
58
|
+
id: z.ZodString;
|
|
59
|
+
slug: z.ZodString;
|
|
60
|
+
name: z.ZodString;
|
|
61
|
+
description: z.ZodString;
|
|
62
|
+
version: z.ZodString;
|
|
63
|
+
tags: z.ZodArray<z.ZodString>;
|
|
64
|
+
layouts: z.ZodArray<z.ZodString>;
|
|
65
|
+
canvas: z.ZodObject<{
|
|
66
|
+
width: z.ZodInt;
|
|
67
|
+
height: z.ZodInt;
|
|
68
|
+
}, z.core.$strip>;
|
|
69
|
+
author: z.ZodObject<{
|
|
70
|
+
name: z.ZodString;
|
|
71
|
+
url: z.ZodOptional<z.ZodString>;
|
|
72
|
+
}, z.core.$strip>;
|
|
73
|
+
license: z.ZodString;
|
|
74
|
+
homepage: z.ZodOptional<z.ZodString>;
|
|
75
|
+
contractVersion: z.ZodString;
|
|
76
|
+
assetCount: z.ZodInt;
|
|
77
|
+
bundle: z.ZodObject<{
|
|
78
|
+
file: z.ZodString;
|
|
79
|
+
url: z.ZodOptional<z.ZodURL>;
|
|
80
|
+
bytes: z.ZodInt;
|
|
81
|
+
sha256: z.ZodString;
|
|
82
|
+
}, z.core.$strip>;
|
|
83
|
+
preview: z.ZodObject<{
|
|
84
|
+
file: z.ZodString;
|
|
85
|
+
url: z.ZodOptional<z.ZodURL>;
|
|
86
|
+
bytes: z.ZodInt;
|
|
87
|
+
sha256: z.ZodString;
|
|
88
|
+
width: z.ZodOptional<z.ZodInt>;
|
|
89
|
+
height: z.ZodOptional<z.ZodInt>;
|
|
90
|
+
}, z.core.$strip>;
|
|
91
|
+
}, z.core.$strip>>;
|
|
92
|
+
}, z.core.$strip>;
|
|
93
|
+
export type FrameIndex = z.infer<typeof FrameIndexSchema>;
|
|
94
|
+
export interface ReleaseSource {
|
|
95
|
+
/** `owner/name`. */
|
|
96
|
+
repository: string;
|
|
97
|
+
/** `vX.Y.Z`. */
|
|
98
|
+
tag: string;
|
|
99
|
+
}
|
|
100
|
+
/** Where a release's assets are downloadable from. */
|
|
101
|
+
export declare function downloadBase(source: ReleaseSource): string;
|
|
102
|
+
/**
|
|
103
|
+
* The stable URL a marketplace polls: GitHub resolves `latest` to the newest
|
|
104
|
+
* published, non-prerelease release, so it needs no API call and no auth. This
|
|
105
|
+
* is the same convention Card Anvil's own updater relies on.
|
|
106
|
+
*/
|
|
107
|
+
export declare function latestIndexUrl(repository: string): string;
|
|
108
|
+
export declare function buildSource(source: ReleaseSource): FrameIndex["source"];
|
|
109
|
+
export declare function buildIndex(options: {
|
|
110
|
+
version: string;
|
|
111
|
+
generator?: string;
|
|
112
|
+
frames: IndexedFrame[];
|
|
113
|
+
source?: ReleaseSource;
|
|
114
|
+
generatedAt?: Date;
|
|
115
|
+
}): FrameIndex;
|