@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
@@ -0,0 +1,104 @@
1
+ import { z } from "zod";
2
+ import { CONTRACT_VERSION } from "../manifest/contract.js";
3
+ /** Bumped only when the index's own shape changes, not the frame contract's. */
4
+ export const FRAME_INDEX_FORMAT_VERSION = 1;
5
+ export const FRAME_INDEX_FILENAME = "frame-index.json";
6
+ const ArtifactSchema = z.object({
7
+ /** Filename as attached to the release. Always present. */
8
+ file: z.string(),
9
+ /** Absolute URL. Absent for a local build, which has no release to point at. */
10
+ url: z.url().optional(),
11
+ bytes: z.int().nonnegative(),
12
+ sha256: z.string(),
13
+ });
14
+ const PreviewSchema = ArtifactSchema.extend({
15
+ width: z.int().positive().optional(),
16
+ height: z.int().positive().optional(),
17
+ });
18
+ export const IndexedFrameSchema = z.object({
19
+ id: z.string(),
20
+ slug: z.string(),
21
+ name: z.string(),
22
+ description: z.string(),
23
+ version: z.string(),
24
+ tags: z.array(z.string()),
25
+ /** Layout names the frame supports, so a browse UI can filter on them. */
26
+ layouts: z.array(z.string()),
27
+ canvas: z.object({ width: z.int(), height: z.int() }),
28
+ author: z.object({ name: z.string(), url: z.string().optional() }),
29
+ license: z.string(),
30
+ homepage: z.string().optional(),
31
+ /** Which frame contract the bundle targets — a client filters on this. */
32
+ contractVersion: z.string(),
33
+ assetCount: z.int().nonnegative(),
34
+ bundle: ArtifactSchema,
35
+ preview: PreviewSchema,
36
+ });
37
+ export const FrameIndexSchema = z.object({
38
+ /** Envelope discriminators, so a consumer handed the wrong JSON can say so. */
39
+ app: z.literal("card-anvil"),
40
+ kind: z.literal("frame-index"),
41
+ formatVersion: z.int().positive(),
42
+ generatedAt: z.string(),
43
+ generator: z.string().optional(),
44
+ /** The contract the generating frame-kit implements. */
45
+ contractVersion: z.string(),
46
+ /** The release version. One version covers every frame in the repository. */
47
+ version: z.string(),
48
+ /**
49
+ * Where this index came from. Omitted for a local build. An aggregator that
50
+ * merged many repositories keeps this so it can still re-fetch each one.
51
+ */
52
+ source: z
53
+ .object({
54
+ kind: z.literal("github-release"),
55
+ repository: z.string(),
56
+ tag: z.string(),
57
+ url: z.url(),
58
+ downloadBase: z.url(),
59
+ /** Always the newest release — what a marketplace polls. */
60
+ latestIndexUrl: z.url(),
61
+ })
62
+ .optional(),
63
+ frames: z.array(IndexedFrameSchema),
64
+ });
65
+ /** Where a release's assets are downloadable from. */
66
+ export function downloadBase(source) {
67
+ return `https://github.com/${source.repository}/releases/download/${source.tag}/`;
68
+ }
69
+ /**
70
+ * The stable URL a marketplace polls: GitHub resolves `latest` to the newest
71
+ * published, non-prerelease release, so it needs no API call and no auth. This
72
+ * is the same convention Card Anvil's own updater relies on.
73
+ */
74
+ export function latestIndexUrl(repository) {
75
+ return `https://github.com/${repository}/releases/latest/download/${FRAME_INDEX_FILENAME}`;
76
+ }
77
+ export function buildSource(source) {
78
+ return {
79
+ kind: "github-release",
80
+ repository: source.repository,
81
+ tag: source.tag,
82
+ url: `https://github.com/${source.repository}/releases/tag/${source.tag}`,
83
+ downloadBase: downloadBase(source),
84
+ latestIndexUrl: latestIndexUrl(source.repository),
85
+ };
86
+ }
87
+ export function buildIndex(options) {
88
+ return {
89
+ app: "card-anvil",
90
+ kind: "frame-index",
91
+ formatVersion: FRAME_INDEX_FORMAT_VERSION,
92
+ generatedAt: (options.generatedAt ?? new Date()).toISOString(),
93
+ ...(options.generator === undefined
94
+ ? {}
95
+ : { generator: options.generator }),
96
+ contractVersion: CONTRACT_VERSION,
97
+ version: options.version,
98
+ ...(options.source === undefined
99
+ ? {}
100
+ : { source: buildSource(options.source) }),
101
+ // Sorted by id so the index is stable and diffable across releases.
102
+ frames: [...options.frames].sort((a, b) => a.id.localeCompare(b.id)),
103
+ };
104
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * sha256 of a file, streamed.
3
+ *
4
+ * Streamed rather than read whole because frame art runs to hundreds of
5
+ * megabytes across a frame, and every asset is hashed on every build.
6
+ */
7
+ export declare function hashFile(file: string): Promise<string>;
8
+ /**
9
+ * Runs `worker` over every item, at most `limit` at a time.
10
+ *
11
+ * Hashing is IO-bound; unbounded concurrency over a few hundred files just
12
+ * exhausts file handles.
13
+ */
14
+ export declare function mapWithConcurrency<T>(items: readonly T[], limit: number, worker: (item: T) => Promise<void>): Promise<void>;
@@ -0,0 +1,32 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { pipeline } from "node:stream/promises";
4
+ /**
5
+ * sha256 of a file, streamed.
6
+ *
7
+ * Streamed rather than read whole because frame art runs to hundreds of
8
+ * megabytes across a frame, and every asset is hashed on every build.
9
+ */
10
+ export async function hashFile(file) {
11
+ const hash = createHash("sha256");
12
+ await pipeline(createReadStream(file), hash);
13
+ return hash.digest("hex");
14
+ }
15
+ /**
16
+ * Runs `worker` over every item, at most `limit` at a time.
17
+ *
18
+ * Hashing is IO-bound; unbounded concurrency over a few hundred files just
19
+ * exhausts file handles.
20
+ */
21
+ export async function mapWithConcurrency(items, limit, worker) {
22
+ let next = 0;
23
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
24
+ while (next < items.length) {
25
+ const item = items[next++];
26
+ if (item !== undefined) {
27
+ await worker(item);
28
+ }
29
+ }
30
+ });
31
+ await Promise.all(runners);
32
+ }
@@ -0,0 +1,13 @@
1
+ export interface ImageSize {
2
+ width: number;
3
+ height: number;
4
+ }
5
+ /**
6
+ * Reads the pixel dimensions of a PNG or JPEG from its header.
7
+ *
8
+ * Hand-rolled rather than pulled from a dependency because it is a few dozen
9
+ * bytes of well-specified header and this runs in every author's CI. Returns
10
+ * `undefined` for anything it does not understand — dimensions are a nicety
11
+ * for a browse UI, never a reason to fail a build.
12
+ */
13
+ export declare function readImageSize(file: string): Promise<ImageSize | undefined>;
@@ -0,0 +1,72 @@
1
+ import { open } from "node:fs/promises";
2
+ /** Enough for a PNG header and for the SOF marker of any sane JPEG. */
3
+ const HEADER_BYTES = 64 * 1024;
4
+ const PNG_SIGNATURE = Buffer.from([
5
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
6
+ ]);
7
+ /**
8
+ * Reads the pixel dimensions of a PNG or JPEG from its header.
9
+ *
10
+ * Hand-rolled rather than pulled from a dependency because it is a few dozen
11
+ * bytes of well-specified header and this runs in every author's CI. Returns
12
+ * `undefined` for anything it does not understand — dimensions are a nicety
13
+ * for a browse UI, never a reason to fail a build.
14
+ */
15
+ export async function readImageSize(file) {
16
+ let handle;
17
+ try {
18
+ handle = await open(file, "r");
19
+ const buffer = Buffer.alloc(HEADER_BYTES);
20
+ const { bytesRead } = await handle.read(buffer, 0, HEADER_BYTES, 0);
21
+ const header = buffer.subarray(0, bytesRead);
22
+ return readPng(header) ?? readJpeg(header);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ finally {
28
+ await handle?.close();
29
+ }
30
+ }
31
+ /** IHDR is always the first chunk: signature (8) + length (4) + type (4). */
32
+ function readPng(header) {
33
+ if (header.length < 24 || !header.subarray(0, 8).equals(PNG_SIGNATURE)) {
34
+ return undefined;
35
+ }
36
+ if (header.subarray(12, 16).toString("latin1") !== "IHDR") {
37
+ return undefined;
38
+ }
39
+ return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
40
+ }
41
+ /**
42
+ * Walks the JPEG marker chain to the start-of-frame, which carries the size.
43
+ * Every other marker is a length-prefixed segment to skip over.
44
+ */
45
+ function readJpeg(header) {
46
+ if (header.length < 4 || header.readUInt16BE(0) !== 0xffd8) {
47
+ return undefined;
48
+ }
49
+ let offset = 2;
50
+ while (offset + 9 < header.length) {
51
+ if (header[offset] !== 0xff) {
52
+ return undefined; // out of sync with the marker chain
53
+ }
54
+ const marker = header[offset + 1];
55
+ if (marker === undefined) {
56
+ return undefined;
57
+ }
58
+ // SOF0-SOF15 carry the frame header; C4, C8 and CC are other things.
59
+ if (marker >= 0xc0 &&
60
+ marker <= 0xcf &&
61
+ marker !== 0xc4 &&
62
+ marker !== 0xc8 &&
63
+ marker !== 0xcc) {
64
+ return {
65
+ height: header.readUInt16BE(offset + 5),
66
+ width: header.readUInt16BE(offset + 7),
67
+ };
68
+ }
69
+ offset += 2 + header.readUInt16BE(offset + 2);
70
+ }
71
+ return undefined;
72
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Packaging: turning authored frames into distributable bundles.
3
+ *
4
+ * Separate from the main entry point because it needs Node and Vite, neither of
5
+ * which a browser or Card Anvil's bundle has. Importing this pulls in the
6
+ * packaging machinery; importing `@cardanvil/frame-kit` does not.
7
+ */
8
+ export { type LoadOptions, type LoadedFrame, type ValidateResult, loadFrames, validateFrames, } from "./api.js";
9
+ export { type BuildOptions, type BuildResult, type BuiltFrame, buildFrames, } from "./build.js";
10
+ export { describeBytes } from "./bundle.js";
11
+ export { type DiscoveredFrame, discoverFrames, relativePosix, } from "./discover.js";
12
+ export { devUrlToFile } from "./devUrl.js";
13
+ export { PackagingError } from "./errors.js";
14
+ export { FRAME_INDEX_FILENAME, FRAME_INDEX_FORMAT_VERSION, type FrameIndex, FrameIndexSchema, type IndexedFrame, IndexedFrameSchema, type ReleaseSource, buildIndex, downloadBase, latestIndexUrl, } from "./frameIndex.js";
15
+ export { type FrameServer, loadFrameExport, startFrameServer, } from "./loadFrame.js";
16
+ export { type Problem, Reporter, type Severity, formatProblem, inGitHubActions, } from "./report.js";
17
+ export { SCHEMA_NAMES, SHIPPED_SCHEMAS, type SchemaName, schemaFilename, toJsonSchema, } from "./schema.js";
18
+ export { stableStringify } from "./stableJson.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Packaging: turning authored frames into distributable bundles.
3
+ *
4
+ * Separate from the main entry point because it needs Node and Vite, neither of
5
+ * which a browser or Card Anvil's bundle has. Importing this pulls in the
6
+ * packaging machinery; importing `@cardanvil/frame-kit` does not.
7
+ */
8
+ export { loadFrames, validateFrames, } from "./api.js";
9
+ export { buildFrames, } from "./build.js";
10
+ export { describeBytes } from "./bundle.js";
11
+ export { discoverFrames, relativePosix, } from "./discover.js";
12
+ export { devUrlToFile } from "./devUrl.js";
13
+ export { PackagingError } from "./errors.js";
14
+ export { FRAME_INDEX_FILENAME, FRAME_INDEX_FORMAT_VERSION, FrameIndexSchema, IndexedFrameSchema, buildIndex, downloadBase, latestIndexUrl, } from "./frameIndex.js";
15
+ export { loadFrameExport, startFrameServer, } from "./loadFrame.js";
16
+ export { Reporter, formatProblem, inGitHubActions, } from "./report.js";
17
+ export { SCHEMA_NAMES, SHIPPED_SCHEMAS, schemaFilename, toJsonSchema, } from "./schema.js";
18
+ export { stableStringify } from "./stableJson.js";
@@ -0,0 +1,24 @@
1
+ import type { ViteDevServer } from "vite";
2
+ export interface FrameServer {
3
+ readonly server: ViteDevServer;
4
+ /** Absolute project root every asset URL is relative to. */
5
+ readonly root: string;
6
+ /** Vite's dev base, the prefix on every asset URL. */
7
+ readonly base: string;
8
+ close(): Promise<void>;
9
+ }
10
+ /**
11
+ * Starts one Vite server for a whole packaging run.
12
+ *
13
+ * Starting it is the expensive part, and frames that extend one another share
14
+ * the module graph, so it is deliberately not per-frame.
15
+ */
16
+ export declare function startFrameServer(root: string): Promise<FrameServer>;
17
+ /**
18
+ * Loads one frame's module and returns the named export, unvalidated.
19
+ *
20
+ * Returns `unknown` rather than trusting `ssrLoadModule`'s `Record<string, any>`
21
+ * — the caller parses it against `FrameSchema`, which is the only thing that
22
+ * decides whether it is a frame.
23
+ */
24
+ export declare function loadFrameExport(frameServer: FrameServer, entryFile: string, exportName: string): Promise<unknown>;
@@ -0,0 +1,90 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { PackagingError } from "./errors.js";
5
+ /**
6
+ * Vite is what turns `import w from "./w.png"` into a URL, so a frame cannot be
7
+ * packaged without it.
8
+ *
9
+ * It is an *optional* peer, imported dynamically: frame-kit's schemas and
10
+ * manifest helpers have to keep working under plain Node, in a browser, and
11
+ * inside Card Anvil's bundle, none of which have Vite. Nothing on those paths
12
+ * ever reaches this module.
13
+ */
14
+ async function importVite() {
15
+ try {
16
+ return await import("vite");
17
+ }
18
+ catch {
19
+ throw new PackagingError("Vite is not installed.\n\n" +
20
+ 'Frames import their art directly (`import w from "./w.png"`), and only\n' +
21
+ "a bundler can turn those imports into files. Install it:\n\n" +
22
+ " pnpm add -D vite\n");
23
+ }
24
+ }
25
+ /**
26
+ * Starts one Vite server for a whole packaging run.
27
+ *
28
+ * Starting it is the expensive part, and frames that extend one another share
29
+ * the module graph, so it is deliberately not per-frame.
30
+ */
31
+ export async function startFrameServer(root) {
32
+ const vite = await importVite();
33
+ const cacheDir = await mkdtemp(path.join(tmpdir(), "frame-kit-"));
34
+ const server = await vite.createServer({
35
+ // Never inherit the author's config. Its `base`, plugins and asset rules
36
+ // would change the URLs we get back, and a frame that only packages
37
+ // correctly under one app's config is not distributable.
38
+ configFile: false,
39
+ root,
40
+ appType: "custom",
41
+ logLevel: "warn",
42
+ // Nothing is ever served over HTTP; the module runner is the whole point.
43
+ server: { middlewareMode: true, hmr: false, watch: null },
44
+ build: {
45
+ // Vite inlines small assets as data URLs in serve mode too, not just at
46
+ // build time. A data URL has no file to package, so an SVG mask under
47
+ // the default 4 KB limit would silently vanish from the bundle.
48
+ assetsInlineLimit: 0,
49
+ },
50
+ optimizeDeps: { noDiscovery: true },
51
+ // Keep the dep cache out of the author's node_modules.
52
+ cacheDir,
53
+ resolve: { dedupe: ["zod"] },
54
+ });
55
+ return {
56
+ server,
57
+ root: path.resolve(root),
58
+ base: server.config.base,
59
+ async close() {
60
+ await server.close();
61
+ await rm(cacheDir, { recursive: true, force: true });
62
+ },
63
+ };
64
+ }
65
+ /**
66
+ * Loads one frame's module and returns the named export, unvalidated.
67
+ *
68
+ * Returns `unknown` rather than trusting `ssrLoadModule`'s `Record<string, any>`
69
+ * — the caller parses it against `FrameSchema`, which is the only thing that
70
+ * decides whether it is a frame.
71
+ */
72
+ export async function loadFrameExport(frameServer, entryFile, exportName) {
73
+ let namespace;
74
+ try {
75
+ namespace = (await frameServer.server.ssrLoadModule(entryFile));
76
+ }
77
+ catch (cause) {
78
+ throw new PackagingError(`could not load ${entryFile}: ${cause instanceof Error ? cause.message : String(cause)}`);
79
+ }
80
+ if (!(exportName in namespace)) {
81
+ const available = Object.keys(namespace)
82
+ .filter((key) => key !== "default" || "default" in namespace)
83
+ .sort()
84
+ .join(", ");
85
+ throw new PackagingError(`${entryFile} has no export named "${exportName}". ` +
86
+ (available ? `It exports: ${available}.` : "It exports nothing.") +
87
+ ` Set "export" in frame.meta.json to the one that is the frame.`);
88
+ }
89
+ return namespace[exportName];
90
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ import { appendFile, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { validateFrames } from "./api.js";
6
+ import { buildFrames } from "./build.js";
7
+ import { describeBytes } from "./bundle.js";
8
+ import { PackagingError } from "./errors.js";
9
+ import { Reporter, inGitHubActions } from "./report.js";
10
+ import { SCHEMA_NAMES, toJsonSchema } from "./schema.js";
11
+ import { stableStringify } from "./stableJson.js";
12
+ const USAGE = `frame-kit — package Card Anvil frames
13
+
14
+ Usage:
15
+ frame-kit validate [options] check every frame, write nothing
16
+ frame-kit build --out <dir> [options] check every frame, then pack each one
17
+ frame-kit schema <name> [--out <file>] print a format as JSON Schema
18
+ (${SCHEMA_NAMES.join(" | ")})
19
+ frame-kit --help
20
+ frame-kit --version
21
+
22
+ Options:
23
+ --root <dir> where to look for frames (default: the current directory)
24
+ --frame <slug> only this frame; repeatable
25
+ --summary-md <file> append a Markdown summary (use "$GITHUB_STEP_SUMMARY")
26
+ --json machine-readable output
27
+
28
+ build only:
29
+ --out <dir> where bundles are written (required)
30
+ --version <x.y.z> version stamped into every frame
31
+ (default: the version in the repository's package.json)
32
+ --repository <o/n> the GitHub repository the release belongs to
33
+ --tag <vX.Y.Z> the release tag; with --repository, puts download URLs
34
+ in the index
35
+
36
+ A frame is any directory containing a frame.meta.json.
37
+ `;
38
+ /** 0 clean · 1 one or more frames failed · 2 the command was used wrongly. */
39
+ const EXIT = { ok: 0, failed: 1, usage: 2 };
40
+ async function packageVersion() {
41
+ const manifest = JSON.parse(await readFile(path.join(import.meta.dirname, "../../package.json"), "utf8"));
42
+ const version = manifest.version;
43
+ return typeof version === "string" ? version : "0.0.0";
44
+ }
45
+ /** The single version every frame in a repository is released under. */
46
+ async function repositoryVersion(root) {
47
+ try {
48
+ const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
49
+ const version = manifest.version;
50
+ if (typeof version === "string") {
51
+ return version;
52
+ }
53
+ }
54
+ catch {
55
+ // No package.json, or an unreadable one — the message below covers both.
56
+ }
57
+ throw new PackagingError("no version to stamp into the frames. Pass --version, or set one in the " +
58
+ "repository's package.json.");
59
+ }
60
+ async function emit(reporter, summaryFile) {
61
+ if (reporter.problems.length > 0) {
62
+ console.error(inGitHubActions() ? reporter.toAnnotations() : reporter.toText());
63
+ }
64
+ if (summaryFile) {
65
+ const body = reporter.ok
66
+ ? "### Frames\n\nEvery frame is valid.\n"
67
+ : `### Frames\n\n${reporter.toMarkdown()}\n`;
68
+ await appendFile(summaryFile, body, "utf8");
69
+ }
70
+ }
71
+ function summarise(reporter, frames) {
72
+ const { length: errors } = reporter.errors;
73
+ const { length: warnings } = reporter.warnings;
74
+ console.log([
75
+ `${String(frames)} frame${frames === 1 ? "" : "s"}`,
76
+ errors > 0
77
+ ? `${String(errors)} error${errors === 1 ? "" : "s"}`
78
+ : undefined,
79
+ warnings > 0
80
+ ? `${String(warnings)} warning${warnings === 1 ? "" : "s"}`
81
+ : undefined,
82
+ ]
83
+ .filter(Boolean)
84
+ .join(", "));
85
+ }
86
+ async function runValidate(root, only, values) {
87
+ const { reporter, frames } = await validateFrames({ root, only });
88
+ if (values.json) {
89
+ console.log(JSON.stringify({ ok: reporter.ok, frames: frames.length, problems: reporter.problems }, null, 2));
90
+ }
91
+ else {
92
+ await emit(reporter, values["summary-md"]);
93
+ summarise(reporter, frames.length);
94
+ }
95
+ return reporter.ok ? EXIT.ok : EXIT.failed;
96
+ }
97
+ async function runBuild(root, only, values) {
98
+ if (values.out === undefined) {
99
+ console.error(`build needs --out <dir>.\n\n${USAGE}`);
100
+ return EXIT.usage;
101
+ }
102
+ const version = values.version ?? (await repositoryVersion(root));
103
+ const reporter = new Reporter();
104
+ const { frames: built } = await buildFrames({
105
+ root,
106
+ only,
107
+ outDir: values.out,
108
+ version,
109
+ generator: `@cardanvil/frame-kit@${await packageVersion()}`,
110
+ // Both or neither: a download URL needs the repository and the tag.
111
+ ...(values.repository !== undefined && values.tag !== undefined
112
+ ? { source: { repository: values.repository, tag: values.tag } }
113
+ : {}),
114
+ }, reporter);
115
+ if (values.json) {
116
+ console.log(JSON.stringify({
117
+ ok: reporter.ok,
118
+ version,
119
+ frames: built.map((frame) => ({
120
+ id: frame.manifest.id,
121
+ slug: frame.loaded.discovered.slug,
122
+ bundle: frame.bundleName,
123
+ bytes: frame.bytes,
124
+ })),
125
+ problems: reporter.problems,
126
+ }, null, 2));
127
+ }
128
+ else {
129
+ await emit(reporter, values["summary-md"]);
130
+ for (const frame of built) {
131
+ console.log(`${frame.bundleName} ${describeBytes(frame.bytes)}`);
132
+ }
133
+ summarise(reporter, built.length);
134
+ }
135
+ return reporter.ok ? EXIT.ok : EXIT.failed;
136
+ }
137
+ function isSchemaName(name) {
138
+ return SCHEMA_NAMES.includes(name);
139
+ }
140
+ async function runSchema(argv) {
141
+ const name = argv[0];
142
+ if (name === undefined || !isSchemaName(name)) {
143
+ console.error(`schema needs one of: ${SCHEMA_NAMES.join(", ")}.
144
+
145
+ ${USAGE}`);
146
+ return EXIT.usage;
147
+ }
148
+ let values;
149
+ try {
150
+ ({ values } = parseArgs({
151
+ args: argv.slice(1),
152
+ options: { out: { type: "string" } },
153
+ strict: true,
154
+ }));
155
+ }
156
+ catch (cause) {
157
+ console.error(`${cause instanceof Error ? cause.message : String(cause)}
158
+
159
+ ${USAGE}`);
160
+ return EXIT.usage;
161
+ }
162
+ const json = stableStringify(toJsonSchema(name));
163
+ if (values.out === undefined) {
164
+ process.stdout.write(json);
165
+ }
166
+ else {
167
+ await writeFile(values.out, json, "utf8");
168
+ }
169
+ return EXIT.ok;
170
+ }
171
+ async function main(argv) {
172
+ const command = argv[0];
173
+ if (command === undefined || command === "--help" || command === "-h") {
174
+ console.log(USAGE);
175
+ return command === undefined ? EXIT.usage : EXIT.ok;
176
+ }
177
+ if (command === "--version" || command === "-v") {
178
+ console.log(await packageVersion());
179
+ return EXIT.ok;
180
+ }
181
+ if (command === "schema") {
182
+ return await runSchema(argv.slice(1));
183
+ }
184
+ if (command !== "validate" && command !== "build") {
185
+ console.error(`Unknown command "${command}".\n\n${USAGE}`);
186
+ return EXIT.usage;
187
+ }
188
+ let values;
189
+ try {
190
+ ({ values } = parseArgs({
191
+ args: argv.slice(1),
192
+ options: {
193
+ root: { type: "string" },
194
+ frame: { type: "string", multiple: true },
195
+ "summary-md": { type: "string" },
196
+ json: { type: "boolean" },
197
+ out: { type: "string" },
198
+ version: { type: "string" },
199
+ repository: { type: "string" },
200
+ tag: { type: "string" },
201
+ },
202
+ strict: true,
203
+ }));
204
+ }
205
+ catch (cause) {
206
+ console.error(`${cause instanceof Error ? cause.message : String(cause)}\n\n${USAGE}`);
207
+ return EXIT.usage;
208
+ }
209
+ const root = path.resolve(values.root ?? process.cwd());
210
+ const only = values.frame ?? [];
211
+ return command === "build"
212
+ ? await runBuild(root, only, values)
213
+ : await runValidate(root, only, values);
214
+ }
215
+ try {
216
+ process.exitCode = await main(process.argv.slice(2));
217
+ }
218
+ catch (cause) {
219
+ // A PackagingError is something the author can act on; anything else is a bug
220
+ // in this tool and deserves its stack.
221
+ if (cause instanceof PackagingError) {
222
+ console.error(cause.message);
223
+ }
224
+ else {
225
+ console.error(cause);
226
+ }
227
+ process.exitCode = EXIT.failed;
228
+ }
@@ -0,0 +1,35 @@
1
+ export type Severity = "error" | "warning";
2
+ /**
3
+ * One thing wrong with one frame.
4
+ *
5
+ * Problems are collected rather than thrown so a single run tells an author
6
+ * everything that needs fixing, not just whatever failed first.
7
+ */
8
+ export interface Problem {
9
+ severity: Severity;
10
+ /** Frame slug, when the problem belongs to a particular frame. */
11
+ frame?: string;
12
+ /** Path relative to the run root, POSIX separators. */
13
+ file?: string;
14
+ /** Property path inside the frame, e.g. `config.layouts.normal.boxes.art`. */
15
+ path?: string;
16
+ message: string;
17
+ }
18
+ /** Renders a problem as one line: `frame › path — message`. */
19
+ export declare function formatProblem(problem: Problem): string;
20
+ export declare class Reporter {
21
+ readonly problems: Problem[];
22
+ add(problem: Problem): void;
23
+ error(problem: Omit<Problem, "severity">): void;
24
+ warn(problem: Omit<Problem, "severity">): void;
25
+ get errors(): Problem[];
26
+ get warnings(): Problem[];
27
+ get ok(): boolean;
28
+ /** Human-readable lines for a terminal. */
29
+ toText(): string;
30
+ /** Workflow commands, one per problem. Empty outside GitHub Actions. */
31
+ toAnnotations(): string;
32
+ toMarkdown(): string;
33
+ }
34
+ /** True when running inside GitHub Actions, where annotations are understood. */
35
+ export declare function inGitHubActions(): boolean;