Public: anyone can read this; the crew can change it.

Jay Moran's display model is on main: a display.json reads into items and selections, and the arcade shelf compiles and is tested #9338

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9329-jay-moran-s-display-model-is-on-main-a-d → main 3 commits fork

  • Merged. scout/9329-jay-moran-s-display-model-is-on-main-a-d was merged into main as 048d0ee.

Review

Sign in to review this change.
11 files changed +1065 −0
scripts/hollow-allow.ts +10
@@ -19,4 +19,14 @@ export type Allowed = {
1919 };
2020
2121 export const ALLOWED: readonly Allowed[] = [
22 {
23 orphan: "src/actions/pin_demos.ts#demoDefaults",
24 reason: "read by src/display/items.ts, which no page serves until the display route lands",
25 issue: 9208,
26 },
27 {
28 orphan: "src/actions/pin_demos.ts#demoFactsAt",
29 reason: "read by src/display/items.ts, which no page serves until the display route lands",
30 issue: 9208,
31 },
2232 ];
scripts/unreached-allow.json +6
@@ -1,9 +1,15 @@
11 [
2 "src/actions/pin_demos.ts",
23 "src/apps/cloud.ts",
34 "src/arcade/cover.ts",
5 "src/arcade/shelf.ts",
46 "src/boards/machine.ts",
57 "src/crew/governor_log.ts",
68 "src/crew/tries.ts",
9 "src/display/config.ts",
10 "src/display/items.ts",
11 "src/display/known.ts",
12 "src/display/selections.ts",
713 "src/flow/alarm_drift.ts",
814 "src/flow/alarm_unproposed.ts",
915 "src/flow/approved_wait.ts",
src/actions/pin_demos.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * What a commit's `forge.json` says about its demos: the two reads a display
3 * item starts from. (Saving, showcasing and capturing demos are not here.)
4 */
5 import type { App } from "../app.ts";
6 import { readFileAt } from "../apps/checkout.ts";
7 import { parseManifest } from "../apps/manifest.ts";
8 import type { Repo } from "../core/repos.ts";
9 import { exactRefOid } from "../git/branches.ts";
10 import type { Demo, Launch } from "../git/builds.ts";
11 import { isSafeOid } from "../git/guard.ts";
12 import { readerFor } from "../git/read.ts";
13 import { repoDir } from "../git/repos.ts";
14
15 /** What a commit's `forge.json` says about its demos. */
16 export interface DemoFacts {
17 /** `app.pages`: the pages it offers to play and download. */
18 readonly pages: readonly string[];
19 /** `app.launch`, when it names one. */
20 readonly launch?: Launch | undefined;
21 }
22
23 /**
24 * The pages and the launch a commit's `forge.json` names — or, for a
25 * commit pinned before the repository named any pages, what its default
26 * branch names now. A page the old build does not serve is simply not
27 * found when played.
28 */
29 export async function demoFactsAt(app: App, repo: Repo, oid: string): Promise<DemoFacts> {
30 if (!isSafeOid(oid)) return { pages: [] };
31 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
32 const read = async (at: string): Promise<DemoFacts> => {
33 const [forgeJson, packageJson] = await Promise.all([
34 readFileAt(dir, at, "forge.json"),
35 readFileAt(dir, at, "package.json"),
36 ]);
37 const manifest = parseManifest(forgeJson, packageJson);
38 if (!manifest.ok) return { pages: [] };
39 return {
40 pages: manifest.value.pages ?? [],
41 ...(manifest.value.launch === undefined ? {} : { launch: manifest.value.launch }),
42 };
43 };
44 const own = await read(oid);
45 if (own.pages.length > 0) return own;
46 // Only a commit this repository holds borrows anything.
47 const stat = await readerFor(dir).stat(oid);
48 if (!stat.ok || stat.value.type !== "commit") return own;
49 const tip = await exactRefOid(dir, `refs/heads/${repo.defaultBranch}`);
50 if (tip === undefined || tip === oid) return own;
51 const now = await read(tip);
52 return { pages: now.pages, ...((own.launch ?? now.launch) === undefined ? {} : { launch: own.launch ?? now.launch }) };
53 }
54
55 /** The facts a pin's demo starts from, before anyone edits them. */
56 export function demoDefaults(repo: Repo, label: string, pages: readonly string[]): Pick<Demo, "name" | "version" | "page"> {
57 return { name: repo.name, version: label, page: pages[0] ?? "/" };
58 }
59
src/apps/manifest.ts +29
@@ -15,6 +15,7 @@ import { err, ok, type Result } from "../kernel/result.ts";
1515 import { parseService, type ServiceSpec } from "./service.ts";
1616 import type { DeclaredLimits } from "./contain.ts";
1717 import { isPublicAddress } from "./egress.ts";
18 import { isLaunch, LAUNCHES, type Launch } from "../git/builds.ts";
1819
1920 export interface AppManifest {
2021 /** The command that serves on `$PORT`. Run through `sh -c`, `exec`-ed. */
@@ -46,6 +47,10 @@ export interface AppManifest {
4647 * or empty is none, exactly as before this field existed.
4748 */
4849 readonly looksAsOwner?: readonly string[] | undefined;
50 /** `app.pages`: paths the app serves as one self-contained page, offered to play and download. */
51 readonly pages?: readonly string[] | undefined;
52 /** `app.launch`: how this repository's demos open unless a pin says otherwise. */
53 readonly launch?: Launch | undefined;
4954 /**
5055 * Set when `app.kind` is `"service"`: declared TCP ports, always on,
5156 * a placement preference and synced volumes. `null` for an ordinary
@@ -151,6 +156,11 @@ export function parseManifest(
151156 if (!looks.ok) return looks;
152157 const looksAsOwner = looksAsOwnerField(app, looks.value);
153158 if (!looksAsOwner.ok) return looksAsOwner;
159 const pages = pagesField(app);
160 if (!pages.ok) return pages;
161 const launch = app["launch"];
162 if (launch !== undefined && !isLaunch(launch))
163 return invalid(`forge.json: app.launch must be one of ${LAUNCHES.map((l) => `"${l}"`).join(", ")}`);
154164 const egress = egressField(parsed.value);
155165 if (!egress.ok) return egress;
156166 const limits = limitsField(app, "app");
@@ -184,6 +194,8 @@ export function parseManifest(
184194 tty: tty.value || null,
185195 looks: looks.value,
186196 looksAsOwner: looksAsOwner.value,
197 pages: pages.value,
198 ...(launch === undefined ? {} : { launch }),
187199 service,
188200 egress: egress.value,
189201 limits: limits.value,
@@ -304,6 +316,23 @@ function looksAsOwnerField(
304316 return ok(paths.value);
305317 }
306318
319 /** How many pages a manifest may offer to play. */
320 export const PAGES_MAX = 20;
321
322 function pagesField(app: Json): Result<readonly string[], ManifestError> {
323 const value = app["pages"];
324 if (value === undefined) return ok([]);
325 if (!Array.isArray(value) || value.length > PAGES_MAX)
326 return invalid(`forge.json: app.pages must be an array of at most ${PAGES_MAX} paths`);
327 const paths: string[] = [];
328 for (const item of value) {
329 if (typeof item !== "string" || !/^\/[A-Za-z0-9._-]*(\/[A-Za-z0-9._-]+)*$/.test(item) || item.length > 120 || item.includes(".."))
330 return invalid('forge.json: app.pages entries are paths like "/" or "/spicy": letters, digits, dot, dash, underscore');
331 if (!paths.includes(item)) paths.push(item);
332 }
333 return ok(paths);
334 }
335
307336 /** A list of page paths, as `app.looks` and `app.looksAsOwner` take them. */
308337 function pathList(
309338 value: unknown,
src/arcade/shelf.ts added +112
@@ -0,0 +1,112 @@
1 /**
2 * The shelf layout's data: a display's items that play as a file and have
3 * their page captured, as cartridges. Items sharing a demo name are one
4 * cartridge, and each is one of its builds, in display order, in the shape
5 * browser-arcade's `games.json` has always had. A demo that opens as a
6 * running preview or as screenshots has no file to play, so the shelf
7 * leaves it out.
8 *
9 * Paths are relative to the display origin's root (`host.ts`); the forge's
10 * own shelf page rewrites them to absolute addresses (`web/pages/display.ts`).
11 * An item whose repository the shelf's viewer cannot read (`open: false`)
12 * is named by its display and opaque id alone — `displays/<owner>/<display>/<id>…`
13 * — and carries no repository, pin or commit.
14 */
15
16 import type { Repo } from "../core/repos.ts";
17 import type { BuildPin, Demo } from "../git/builds.ts";
18 import type { DisplayItem } from "../display/items.ts";
19 import { pageFile } from "./carts.ts";
20
21 export interface ShelfBuild {
22 readonly id: string;
23 readonly name: string;
24 /** The capture, relative to the Arcade's root. */
25 readonly file: string;
26 /** An uploaded cover, already titled. */
27 readonly label?: string;
28 /** The page's picture, for a cover drawn in the house style. */
29 readonly art?: string;
30 readonly titled?: boolean;
31 readonly download: string;
32 readonly date: string;
33 readonly size: number;
34 /** Where it came from: `owner/repo`, the pin and its commit; absent for an item not open to the viewer. */
35 readonly repo?: string;
36 readonly pin?: string;
37 readonly oid?: string;
38 }
39
40 export interface ShelfGame {
41 readonly id: string;
42 readonly name: string;
43 readonly producer: string;
44 readonly builds: ShelfBuild[];
45 }
46
47 /** One item on the shelf, as the host serves its files. */
48 export interface ShelfEntry {
49 readonly repo: Repo;
50 readonly pin: BuildPin;
51 readonly cart: Demo;
52 readonly item: DisplayItem;
53 }
54
55 export interface Shelf {
56 readonly games: ShelfGame[];
57 /** Each relative path the shelf names, to the item it belongs to. */
58 readonly entries: Map<string, ShelfEntry>;
59 }
60
61 export const slug = (s: string): string =>
62 s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "cart";
63
64 export const cartFile = (repo: Pick<Repo, "ownerName" | "name">, oid: string, page: string): string =>
65 `carts/${repo.ownerName}/${repo.name}/${oid}/${pageFile(page)}.html`;
66 export const coverFile = (repo: Repo, label: string): string => `labels/${repo.ownerName}/${repo.name}/${label}.png`;
67 export const artFile = (repo: Repo, oid: string, page: string): string =>
68 `art/${repo.ownerName}/${repo.name}/${oid}/${pageFile(page)}.png`;
69
70 type Shown = { readonly display: Pick<Repo, "ownerName" | "name">; readonly id: string };
71 const shownRoot = (item: Shown): string => `displays/${item.display.ownerName}/${item.display.name}/${item.id}`;
72 /** A published item's page on the display origin, by its display and id. */
73 export const shownFile = (item: Shown): string => `${shownRoot(item)}.html`;
74 /** A published item's cover, and its page's picture, on the display origin. */
75 export const shownCover = (item: Shown): string => `${shownRoot(item)}/cover.png`;
76 export const shownArt = (item: Shown): string => `${shownRoot(item)}/art.png`;
77
78 /** The shelf a display's items make; `producer` is the shelf layout's option. */
79 export function shelfOf(items: readonly DisplayItem[], producer: string | undefined): Shelf {
80 const entries = new Map<string, ShelfEntry>();
81 const byName = new Map<string, { name: string; builds: ShelfBuild[] }>();
82 for (const item of items) {
83 const { repo, pin, demo: cart } = item;
84 if (item.launch !== "file" || item.captured === undefined) continue;
85 const entry = { repo, pin, cart, item };
86 const file = item.open ? cartFile(repo, pin.oid, cart.page) : shownFile(item);
87 entries.set(file, entry);
88 const cover = cart.cover === undefined ? undefined : item.open ? coverFile(repo, pin.label) : shownCover(item);
89 if (cover !== undefined) entries.set(cover, entry);
90 const art = cover === undefined && item.art ? (item.open ? artFile(repo, pin.oid, cart.page) : shownArt(item)) : undefined;
91 if (art !== undefined) entries.set(art, entry);
92 const game = byName.get(cart.name) ?? { name: cart.name, builds: [] };
93 byName.set(cart.name, game);
94 // A build's id is unique within its cart: a repeated version takes its pin's label.
95 const taken = game.builds.some((b) => b.id === slug(cart.version));
96 const id = !taken ? slug(cart.version) : item.open ? `${slug(cart.version)}-${slug(pin.label)}` : `${slug(cart.version)}-${item.id.slice(0, 6).toLowerCase()}`;
97 game.builds.push({
98 id,
99 name: cart.version,
100 file,
101 ...(cover === undefined ? {} : { label: cover, titled: true }),
102 ...(art === undefined ? {} : { art }),
103 download: `${slug(cart.name)}-${slug(cart.version)}.html`,
104 date: pin.pinnedAt.slice(0, 10),
105 size: item.captured,
106 ...(item.open ? { repo: `${repo.ownerName}/${repo.name}`, pin: pin.label, oid: pin.oid } : {}),
107 });
108 }
109 // Cartridges in the order their first build stands in the display.
110 const games = [...byName.values()].map((g) => ({ id: slug(g.name), name: g.name, producer: producer ?? "", builds: g.builds }));
111 return { games, entries };
112 }
src/display/config.ts added +217
@@ -0,0 +1,217 @@
1 /**
2 * A display: a page of demos, defined by `display.json` at a repository's
3 * root and read from its default branch, so git is the record. A display
4 * adds no primitive: it is a view over pins (`git/builds.ts`). `builds`,
5 * when present, is the display's chosen builds in their order, showcased
6 * or not; when absent, the display holds every showcased pin in `sources`,
7 * in `order`. `sources` stays the pool the page lists either way.
8 *
9 * ```json
10 * {
11 * "title": "Arcade",
12 * "layout": "shelf",
13 * "sources": ["jmoran", "common-arcade/kart-live"],
14 * "order": ["common-arcade/kart-live:fiesta-1.0"],
15 * "builds": ["common-arcade/kart-live:fiesta-1.0", "jmoran/nova-moves:iter-5-brawl"],
16 * "demos": { "jmoran/nova-moves:iter-5-brawl": { "launch": "file" } },
17 * "shelf": { "producer": "jbox" }
18 * }
19 * ```
20 *
21 * Parsing fails closed: an unknown key, a wrong type or a bad value is an
22 * error naming what is wrong, and the display page shows that error rather
23 * than a guess.
24 */
25
26 import type { App } from "../app.ts";
27 import type { Repo } from "../core/repos.ts";
28 import { exactRefOid } from "../git/branches.ts";
29 import { isLaunch, isPinLabel, type Launch } from "../git/builds.ts";
30 import { readFileAt } from "../apps/checkout.ts";
31 import { repoDir } from "../git/repos.ts";
32 import { err, ok, type Result } from "../kernel/result.ts";
33
34 export const DISPLAY_FILE = "display.json";
35
36 export const LAYOUTS = ["shelf", "grid", "graph"] as const;
37 export type Layout = (typeof LAYOUTS)[number];
38
39 /** Where a display's demos come from: every repository an owner has, or one repository. */
40 export type DisplaySource =
41 | { readonly kind: "owner"; readonly owner: string }
42 | { readonly kind: "repo"; readonly owner: string; readonly name: string };
43
44 export interface DisplayConfig {
45 readonly title: string;
46 readonly layout: Layout;
47 readonly sources: readonly DisplaySource[];
48 /** Demo keys, `owner/name:label`, placed first in this order. */
49 readonly order: readonly string[];
50 /** The display's chosen builds, `owner/name:label`, in order; undefined when the file names none. */
51 readonly builds?: readonly string[] | undefined;
52 /** Per-demo overrides, by key. */
53 readonly demos: ReadonlyMap<string, { readonly launch?: Launch }>;
54 /** The `shelf` layout's options. */
55 readonly shelf: { readonly producer?: string };
56 }
57
58 /** The most sources, ordered keys and overrides one file may name. */
59 export const SOURCES_MAX = 50;
60 export const ORDER_MAX = 500;
61 /** The largest `display.json` read. */
62 export const DISPLAY_FILE_MAX = 64 * 1024;
63
64 const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
65
66 /** A demo's key: `owner/name:label`. */
67 export const demoKey = (owner: string, name: string, label: string): string => `${owner}/${name}:${label}`;
68
69 /** Whether `key` is `owner/name:label`. */
70 export function isDemoKey(key: string): boolean {
71 const at = key.lastIndexOf(":");
72 if (at === -1) return false;
73 const [owner, name, extra] = key.slice(0, at).split("/");
74 return owner !== undefined && name !== undefined && extra === undefined && NAME.test(owner) && NAME.test(name) && isPinLabel(key.slice(at + 1));
75 }
76
77 const isLine = (s: unknown, max: number): s is string =>
78 typeof s === "string" && s.length > 0 && s.length <= max && !/[\x00-\x1f\x7f<>]/.test(s) && s.trim() === s;
79
80 const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
81
82 const TOP_KEYS = new Set(["title", "layout", "sources", "order", "builds", "demos", ...LAYOUTS]);
83
84 /** `display.json`'s text, checked. Every error starts with the file's name. */
85 export function parseDisplay(text: string): Result<DisplayConfig, string> {
86 const bad = (why: string) => err(`${DISPLAY_FILE}: ${why}`);
87 if (text.length > DISPLAY_FILE_MAX) return bad(`larger than ${DISPLAY_FILE_MAX / 1024} KB`);
88 let raw: unknown;
89 try {
90 raw = JSON.parse(text);
91 } catch (e) {
92 return bad(`not valid JSON (${e instanceof Error ? e.message : String(e)})`);
93 }
94 if (!isObject(raw)) return bad("must be a JSON object");
95 for (const key of Object.keys(raw)) if (!TOP_KEYS.has(key)) return bad(`unknown key "${key.slice(0, 40)}"`);
96 if (!isLine(raw.title, 80)) return bad("title is required: one line, at most 80 characters");
97 if (typeof raw.layout !== "string" || !(LAYOUTS as readonly string[]).includes(raw.layout))
98 return bad("layout must be shelf, grid or graph");
99 const layout = raw.layout as Layout;
100
101 if (!Array.isArray(raw.sources) || raw.sources.length === 0) return bad("sources must be a list of owners or owner/name repositories");
102 if (raw.sources.length > SOURCES_MAX) return bad(`sources names at most ${SOURCES_MAX} entries`);
103 const sources: DisplaySource[] = [];
104 for (const s of raw.sources) {
105 const parts = typeof s === "string" ? s.split("/") : [];
106 if (parts.length === 1 && NAME.test(parts[0]!)) sources.push({ kind: "owner", owner: parts[0]! });
107 else if (parts.length === 2 && NAME.test(parts[0]!) && NAME.test(parts[1]!))
108 sources.push({ kind: "repo", owner: parts[0]!, name: parts[1]! });
109 else return bad(`source ${JSON.stringify(s).slice(0, 60)} is not an owner or owner/name`);
110 }
111
112 const order: string[] = [];
113 if (raw.order !== undefined) {
114 if (!Array.isArray(raw.order)) return bad("order must be a list of owner/name:label");
115 if (raw.order.length > ORDER_MAX) return bad(`order names at most ${ORDER_MAX} demos`);
116 for (const k of raw.order) {
117 if (typeof k !== "string" || !isDemoKey(k)) return bad(`order entry ${JSON.stringify(k).slice(0, 60)} is not owner/name:label`);
118 if (!order.includes(k)) order.push(k);
119 }
120 }
121
122 const builds = parseBuilds(raw.builds);
123 if (!builds.ok) return bad(builds.error);
124
125 const demos = new Map<string, { launch?: Launch }>();
126 if (raw.demos !== undefined) {
127 if (!isObject(raw.demos)) return bad("demos must be an object keyed by owner/name:label");
128 const keys = Object.keys(raw.demos);
129 if (keys.length > ORDER_MAX) return bad(`demos names at most ${ORDER_MAX} demos`);
130 for (const k of keys) {
131 if (!isDemoKey(k)) return bad(`demos key ${JSON.stringify(k).slice(0, 60)} is not owner/name:label`);
132 const v = raw.demos[k];
133 if (!isObject(v)) return bad(`demos ${k} must be an object`);
134 for (const f of Object.keys(v)) if (f !== "launch") return bad(`demos ${k}: unknown key "${f.slice(0, 40)}"`);
135 if (v.launch !== undefined && !isLaunch(v.launch)) return bad(`demos ${k}: launch must be file, env or shots`);
136 demos.set(k, isLaunch(v.launch) ? { launch: v.launch } : {});
137 }
138 }
139
140 let shelf: { producer?: string } = {};
141 for (const name of LAYOUTS) {
142 const options = raw[name];
143 if (options === undefined) continue;
144 if (!isObject(options)) return bad(`${name} must be an object of the ${name} layout's options`);
145 for (const f of Object.keys(options))
146 if (!(name === "shelf" && f === "producer")) return bad(`${name}: unknown option "${f.slice(0, 40)}"`);
147 if (name === "shelf" && options.producer !== undefined) {
148 if (!isLine(options.producer, 40)) return bad("shelf.producer must be one line, at most 40 characters");
149 shelf = { producer: options.producer };
150 }
151 }
152 return ok({ title: raw.title, layout, sources, order, ...(builds.value === undefined ? {} : { builds: builds.value }), demos, shelf });
153 }
154
155 /**
156 * `builds`, checked: absent, or a list of distinct `owner/name:label`, at
157 * most `ORDER_MAX`. A repeated key is an error, not a quiet merge: the list
158 * is an order a person chose.
159 */
160 export function parseBuilds(raw: unknown): Result<readonly string[] | undefined, string> {
161 if (raw === undefined) return ok(undefined);
162 if (!Array.isArray(raw)) return err("builds must be a list of owner/name:label");
163 if (raw.length > ORDER_MAX) return err(`builds names at most ${ORDER_MAX} builds`);
164 const out: string[] = [];
165 for (const k of raw) {
166 if (typeof k !== "string" || !isDemoKey(k)) return err(`builds entry ${JSON.stringify(k).slice(0, 60)} is not owner/name:label`);
167 if (out.includes(k)) return err(`builds names ${k} twice`);
168 out.push(k);
169 }
170 return ok(out);
171 }
172
173 /**
174 * A repository's display, read from the tip of its default branch:
175 * undefined when it has no `display.json`, an error when the file is bad.
176 */
177 export async function readDisplay(app: App, repo: Repo): Promise<Result<DisplayConfig, string> | undefined> {
178 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
179 const tip = await exactRefOid(dir, `refs/heads/${repo.defaultBranch}`);
180 if (tip === undefined) return undefined;
181 const text = await readFileAt(dir, tip, DISPLAY_FILE);
182 if (text === undefined) return undefined;
183 return parseDisplay(text);
184 }
185
186 /**
187 * Whether a repository has a display, as last read: a repository header
188 * asks on every page and must not wait on git, so the answer is read in
189 * the background and kept a short while (`hasDisplay`).
190 */
191 const KNOWN_TTL_MS = 15_000;
192 const KNOWN_MAX = 2000;
193 const known = new Map<string, { has: boolean; at: number }>();
194
195 function remember(repo: Repo, has: boolean): void {
196 known.delete(repo.id);
197 known.set(repo.id, { has, at: Date.now() });
198 if (known.size > KNOWN_MAX) known.delete(known.keys().next().value!);
199 }
200
201 /** Read whether `repo` has a display now, and remember it. */
202 export async function checkDisplay(app: App, repo: Repo): Promise<boolean> {
203 const has = (await readDisplay(app, repo)) !== undefined;
204 remember(repo, has);
205 return has;
206 }
207
208 /** Whether `repo` had a display when last read; a stale answer is read again in the background. */
209 export function hasDisplay(app: App, repo: Repo): boolean {
210 const seen = known.get(repo.id);
211 if (seen === undefined || Date.now() - seen.at > KNOWN_TTL_MS) {
212 // Held as known until the read answers, so one page does not start many.
213 remember(repo, seen?.has ?? false);
214 void checkDisplay(app, repo).catch(() => undefined);
215 }
216 return seen?.has ?? false;
217 }
src/display/items.ts added +236
@@ -0,0 +1,236 @@
1 /**
2 * A display's items: the builds its `builds` names, else every pin in its
3 * sources whose demo is in the showcase. Showcase publishes the demo: a writer turning it on publishes
4 * that one demo — its captured page, screenshots, cover, name, version and
5 * captions — to anyone who can see a display that includes it, whether or
6 * not they can read the repository it lives in. The repository stays
7 * private: to a viewer who cannot read it, an item is `open: false`, and
8 * the display names it by its opaque `id` alone, never by `owner/name`,
9 * and serves its assets only through the display's own routes
10 * (`display/launch.ts`).
11 *
12 * Order: `builds`' order when it is given. Otherwise the keys `order`
13 * names, in that order, then everything else, oldest pinned first, so a
14 * new demo joins the end.
15 */
16
17 import { createHmac } from "node:crypto";
18 import { statSync } from "node:fs";
19 import type { App } from "../app.ts";
20 import { demoDefaults, demoFactsAt } from "../actions/pin_demos.ts";
21 import { capturePath } from "../arcade/carts.ts";
22 import { canRead, getRepo, listAllReposByOwner, ownerInStanding, type Repo } from "../core/repos.ts";
23 import type { User } from "../core/users.ts";
24 import { isCopiedPin, launchOf, listBuildPins, type BuildPin, type Demo, type Launch } from "../git/builds.ts";
25 import { repoDir } from "../git/repos.ts";
26 import { loadKey } from "../kernel/secret.ts";
27 import { demoKey, type DisplayConfig } from "./config.ts";
28
29 export interface DisplayItem {
30 /** `owner/name:label`. Never shown to a viewer for whom the item is not `open`. */
31 readonly key: string;
32 /** The item's name on this display alone: opaque, keyed by the forge's secret (`demoId`). */
33 readonly id: string;
34 /** Whether the viewer reads the item's repository, and so may see which it is. */
35 readonly open: boolean;
36 /** The repository whose display this is. */
37 readonly display: Repo;
38 readonly repo: Repo;
39 readonly pin: BuildPin;
40 readonly demo: Demo;
41 /** How it opens on this display: its override, else the pin's own. */
42 readonly launch: Launch;
43 /** The size of its captured page, when there is one. */
44 readonly captured: number | undefined;
45 /** Whether a picture of its captured page is kept. */
46 readonly art: boolean;
47 }
48
49 const sizeOf = (path: string): number | undefined => {
50 try {
51 return statSync(path).size;
52 } catch {
53 return undefined;
54 }
55 };
56
57 /**
58 * A demo's id on one display: an HMAC of the display and the pin under the
59 * forge's secret, so it names no repository, and an id from one display
60 * resolves on no other.
61 */
62 export function demoId(dataDir: string, display: Pick<Repo, "id">, repo: Pick<Repo, "id">, label: string): string {
63 return createHmac("sha256", loadKey(dataDir)).update(`display-demo:${display.id}:${repo.id}:${label}`).digest("base64url").slice(0, 22);
64 }
65
66 /** Whether `s` has an id's shape. */
67 export const isDemoId = (s: string): boolean => /^[A-Za-z0-9_-]{22}$/.test(s);
68
69 /** The repositories a display's sources name, private ones too, each once. */
70 export function sourceRepos(app: App, config: DisplayConfig): Repo[] {
71 const out = new Map<string, Repo>();
72 for (const source of config.sources) {
73 const repos =
74 source.kind === "owner"
75 ? listAllReposByOwner(app.db, source.owner, 100)
76 : [getRepo(app.db, source.owner, source.name)].filter((r): r is Repo => r !== undefined);
77 for (const repo of repos) if (!out.has(repo.id) && ownerInStanding(app.db, repo)) out.set(repo.id, repo);
78 }
79 return [...out.values()];
80 }
81
82 /**
83 * The display's items, in display order, each marked `open` as `viewer`
84 * reads its repository. The caller has already checked that `viewer` reads
85 * the display itself.
86 *
87 * With `builds`, the items are those builds in that order, showcased or
88 * not: a showcased one is published to every viewer, as below; one not
89 * showcased is seen only by a viewer who reads its repository. Without
90 * `builds`, every showcased pin in `sources`, in `order`.
91 */
92 export async function displayItems(app: App, display: Repo, config: DisplayConfig, viewer: User | undefined): Promise<DisplayItem[]> {
93 if (config.builds !== undefined) return chosenItems(app, display, config, config.builds, viewer);
94 const found: DisplayItem[] = [];
95 for (const { repo, pin } of await showcasedPins(app, config))
96 found.push(await itemOf(app, display, config, repo, pin, pin.demo!, canRead(app.db, repo, viewer)));
97 return orderItems(found, config.order);
98 }
99
100 /** Every showcased pin of the display's sources, each once: today's rule, before `builds`. */
101 async function showcasedPins(app: App, config: DisplayConfig): Promise<{ repo: Repo; pin: BuildPin }[]> {
102 const out: { repo: Repo; pin: BuildPin }[] = [];
103 const repos = sourceRepos(app, config);
104 const pinsOf = new Map(
105 await Promise.all(repos.map(async (r) => [r.id, await listBuildPins(repoDir(app.reposRoot, r.ownerName, r.name))] as const)),
106 );
107 for (const repo of repos) {
108 // A fork carries its parent's pins: one both sources hold shows once, from the parent.
109 const parentPins = repo.forkParentId === null ? undefined : pinsOf.get(repo.forkParentId);
110 for (const pin of pinsOf.get(repo.id) ?? []) {
111 if (pin.demo === undefined || !pin.demo.showcase) continue;
112 if (parentPins !== undefined && isCopiedPin(pin, parentPins)) continue;
113 out.push({ repo, pin });
114 }
115 }
116 return out;
117 }
118
119 /**
120 * The display's selection as keys, in order, as `viewer` may name them:
121 * `builds` when the file gives it, else the showcased rule's keys of the
122 * repositories `viewer` reads (all of them, for "everyone": the forge's own
123 * view, never shown). Reads no launch facts, so a page that lists
124 * many displays' selections stays cheap.
125 */
126 export async function selectionKeys(app: App, config: DisplayConfig, viewer: User | undefined | "everyone"): Promise<string[]> {
127 if (config.builds !== undefined) return [...config.builds];
128 const found = (await showcasedPins(app, config))
129 .filter(({ repo }) => viewer === "everyone" || canRead(app.db, repo, viewer))
130 .map(({ repo, pin }) => ({ key: demoKey(repo.ownerName, repo.name, pin.label), pin }));
131 return orderItems(found, config.order).map((i) => i.key);
132 }
133
134 /**
135 * Items for the given keys, in that order, as `viewer` may see them: what a
136 * display's page draws for builds that are not on it yet (its sidebar).
137 */
138 export const itemsOfKeys = (app: App, display: Repo, config: DisplayConfig, keys: readonly string[], viewer: User | undefined): Promise<DisplayItem[]> =>
139 chosenItems(app, display, config, keys, viewer);
140
141 /** The chosen builds a viewer may see on the display, in the chosen order. */
142 async function chosenItems(app: App, display: Repo, config: DisplayConfig, builds: readonly string[], viewer: User | undefined): Promise<DisplayItem[]> {
143 const byRepo = new Map<string, { repo: Repo | undefined; pins: Map<string, BuildPin> }>();
144 const out: DisplayItem[] = [];
145 for (const key of builds) {
146 const at = key.lastIndexOf(":");
147 const [owner = "", name = ""] = key.slice(0, at).split("/");
148 const label = key.slice(at + 1);
149 const repoKey = `${owner}/${name}`;
150 let held = byRepo.get(repoKey);
151 if (held === undefined) {
152 const found = getRepo(app.db, owner, name);
153 const repo = found !== undefined && ownerInStanding(app.db, found) ? found : undefined;
154 const pins = repo === undefined ? [] : await listBuildPins(repoDir(app.reposRoot, repo.ownerName, repo.name));
155 held = { repo, pins: new Map(pins.map((p) => [p.label, p])) };
156 byRepo.set(repoKey, held);
157 }
158 const pin = held.pins.get(label);
159 if (held.repo === undefined || pin === undefined) continue;
160 const open = canRead(app.db, held.repo, viewer);
161 const showcased = pin.demo?.showcase === true;
162 // Showcase publishes; anything else on a display is for its repository's readers alone.
163 if (!showcased && !open) continue;
164 out.push(await itemOf(app, display, config, held.repo, pin, pin.demo ?? (await plainDemo(app, held.repo, pin)), open));
165 }
166 return out;
167 }
168
169 /** What a display shows for a pin that names no demo: its repository and label, its default page, not showcased. */
170 async function plainDemo(app: App, repo: Repo, pin: BuildPin): Promise<Demo> {
171 const facts = await demoFactsAt(app, repo, pin.oid);
172 return { ...demoDefaults(repo, pin.label, facts.pages), version: pin.label, showcase: false };
173 }
174
175 async function itemOf(app: App, display: Repo, config: DisplayConfig, repo: Repo, pin: BuildPin, demo: Demo, open: boolean): Promise<DisplayItem> {
176 const key = demoKey(repo.ownerName, repo.name, pin.label);
177 const override = config.demos.get(key)?.launch;
178 const launch =
179 override ?? demo.launch ?? (await (async () => {
180 const facts = await demoFactsAt(app, repo, pin.oid);
181 return launchOf(demo, facts.launch, facts.pages);
182 })());
183 const base = capturePath(app.config.dataDir, repo, pin.oid, demo.page);
184 return {
185 key,
186 id: demoId(app.config.dataDir, display, repo, pin.label),
187 open,
188 display,
189 repo,
190 pin,
191 demo,
192 launch,
193 captured: sizeOf(`${base}.html`),
194 art: sizeOf(`${base}.png`) !== undefined,
195 };
196 }
197
198 /** The item `id` names on a display, or undefined: unshowcased, gone, or from another display. */
199 export async function displayItem(app: App, display: Repo, config: DisplayConfig, id: string): Promise<DisplayItem | undefined> {
200 if (!isDemoId(id)) return undefined;
201 return (await displayItems(app, display, config, undefined)).find((i) => i.id === id);
202 }
203
204 /** `order`'s keys first, in its order; the rest oldest pinned first. */
205 export function orderItems<T extends { readonly key: string; readonly pin: Pick<BuildPin, "pinnedAt"> }>(
206 items: readonly T[],
207 order: readonly string[],
208 ): T[] {
209 const byKey = new Map(items.map((i) => [i.key, i]));
210 const placed = order.map((k) => byKey.get(k)).filter((i): i is T => i !== undefined);
211 const named = new Set(order);
212 const rest = items
213 .filter((i) => !named.has(i.key))
214 .sort((a, b) => (a.pin.pinnedAt < b.pin.pinnedAt ? -1 : a.pin.pinnedAt > b.pin.pinnedAt ? 1 : a.key < b.key ? -1 : 1));
215 return [...placed, ...rest];
216 }
217
218 /**
219 * A person's new selection for a display, made whole: `named` is the list
220 * they gave, in their order, of builds they may name; every build already on
221 * the display from a repository they cannot read keeps its place, so a page
222 * that never showed its key cannot drop it.
223 */
224 export async function keepUnnamed(app: App, config: DisplayConfig, named: readonly string[], viewer: User | undefined): Promise<string[]> {
225 const current = await selectionKeys(app, config, "everyone");
226 const readable = (key: string): boolean => {
227 const [owner = "", name = ""] = key.slice(0, key.lastIndexOf(":")).split("/");
228 const repo = getRepo(app.db, owner, name);
229 return repo !== undefined && canRead(app.db, repo, viewer);
230 };
231 const out = named.filter(readable);
232 current.forEach((key, at) => {
233 if (!readable(key) && !out.includes(key)) out.splice(Math.min(at, out.length), 0, key);
234 });
235 return out;
236 }
src/display/known.ts added +253
@@ -0,0 +1,253 @@
1 /**
2 * What the forge last read of each repository's display (`display.json` on
3 * its default branch) and showcase (how many of its pins are showcased),
4 * kept in memory so a page that lists them — `/displays` and its New display
5 * picker — reads git once per repository, not once per request.
6 *
7 * An entry is read again when its repository's default branch may have
8 * moved: a `push`, a `pull.merged` or a `repo.*` event drops it. A
9 * showcase count drops on those and on any `build.*` event (a pin, a demo,
10 * the showcase switch). A ref that moves with no event (an origin's sync)
11 * is caught by age: an entry older than `FRESH_MS` is answered as it stands
12 * and read again in the background, the way the repository header's
13 * Display link is (`hasDisplay`). Git stays the record: this is a cache,
14 * empty on boot.
15 */
16
17 import type { App } from "../app.ts";
18 import { listAdminOwnedRepos, listEveryRepo, readableRepoIds, repoDatesFor, type Repo } from "../core/repos.ts";
19 import type { User } from "../core/users.ts";
20 import { listBuildPins } from "../git/builds.ts";
21 import { repoDir } from "../git/repos.ts";
22 import type { Result } from "../kernel/result.ts";
23 import { readDisplay, type DisplayConfig } from "./config.ts";
24 import { displayItems, sourceRepos } from "./items.ts";
25
26 /** How long an entry is answered without a background read. */
27 const FRESH_MS = 10 * 60_000;
28 /** How many repositories a cold listing reads from git at once. */
29 const READS_AT_ONCE = 8;
30
31 /** One kind of fact per repository id: kept, read once at a time, dropped on an event. */
32 class Kept<T> {
33 private readonly kept = new Map<string, { value: T; at: number }>();
34 private readonly reading = new Map<string, Promise<T>>();
35 /** Bumped on every drop, so a read that started before it is not kept. */
36 private readonly epoch = new Map<string, number>();
37
38 drop(key: string): void {
39 this.kept.delete(key);
40 this.reading.delete(key);
41 this.epoch.set(key, (this.epoch.get(key) ?? 0) + 1);
42 }
43
44 keys(): IterableIterator<string> {
45 return this.kept.keys();
46 }
47
48 peek(key: string): T | undefined {
49 return this.kept.get(key)?.value;
50 }
51
52 /** The kept value, or a read; a stale value is answered and read again behind it. */
53 async get(key: string, read: () => Promise<T>): Promise<T> {
54 const seen = this.kept.get(key);
55 if (seen !== undefined) {
56 if (Date.now() - seen.at > FRESH_MS) void this.load(key, read).catch(() => undefined);
57 return seen.value;
58 }
59 return this.load(key, read);
60 }
61
62 private load(key: string, read: () => Promise<T>): Promise<T> {
63 const going = this.reading.get(key);
64 if (going !== undefined) return going;
65 const started = this.epoch.get(key) ?? 0;
66 const p = read()
67 .then((value) => {
68 if ((this.epoch.get(key) ?? 0) === started) this.kept.set(key, { value, at: Date.now() });
69 return value;
70 })
71 .finally(() => {
72 if (this.reading.get(key) === p) this.reading.delete(key);
73 });
74 this.reading.set(key, p);
75 return p;
76 }
77 }
78
79 /** The Arcade's pick as last read: kept until read again, so a sync reader always has an answer. */
80 interface ArcadePick {
81 value: Repo | undefined;
82 at: number;
83 /** Bumped on every event that may change it; a pick read before the bump is stale. */
84 epoch: number;
85 readEpoch: number;
86 reading: Promise<Repo | undefined> | undefined;
87 }
88
89 interface Known {
90 readonly displays: Kept<Result<DisplayConfig, string> | undefined>;
91 readonly arcade: ArcadePick;
92 readonly showcased: Kept<number>;
93 /** A display's demo count, with the source repositories it was counted over. */
94 readonly counts: Kept<{ readonly n: number; readonly sources: readonly string[] }>;
95 }
96
97 const byApp = new WeakMap<App, Known>();
98
99 function knownFor(app: App): Known {
100 let k = byApp.get(app);
101 if (k !== undefined) return k;
102 const made: Known = {
103 displays: new Kept(),
104 showcased: new Kept(),
105 counts: new Kept(),
106 arcade: { value: undefined, at: 0, epoch: 1, readEpoch: 0, reading: undefined },
107 };
108 app.bus.subscribe((event) => {
109 const id = event.repoId;
110 if (id === null) return;
111 const moved = event.kind === "push" || event.kind === "pull.merged" || event.kind.startsWith("repo.");
112 const pins = moved || event.kind.startsWith("build.");
113 if (moved) {
114 made.displays.drop(id);
115 made.counts.drop(id);
116 made.arcade.epoch++;
117 }
118 if (!pins) return;
119 made.showcased.drop(id);
120 // A count over this repository, or over an owner that may have gained or lost one.
121 for (const key of [...made.counts.keys()]) {
122 const c = made.counts.peek(key);
123 if (c !== undefined && (event.kind.startsWith("repo.") || c.sources.includes(id))) made.counts.drop(key);
124 }
125 });
126 byApp.set(app, made);
127 k = made;
128 return k;
129 }
130
131 /** `repo`'s display as last read: undefined when it has none, an error when its file is bad. */
132 export function displayOf(app: App, repo: Repo): Promise<Result<DisplayConfig, string> | undefined> {
133 return knownFor(app).displays.get(repo.id, () => readDisplay(app, repo));
134 }
135
136 /** The title that makes a site admin's display the Arcade's. */
137 export const ARCADE_TITLE = "arcade-builds";
138
139 /**
140 * The Arcade's display repository: of the repositories site admins own,
141 * the one whose display is titled exactly `arcade-builds`; of several, the
142 * most recently pushed to. Read again when an event may have moved it, or
143 * when it is older than `FRESH_MS`.
144 */
145 export function arcadeRepoOf(app: App): Promise<Repo | undefined> {
146 const pick = knownFor(app).arcade;
147 if (pick.readEpoch === pick.epoch && Date.now() - pick.at <= FRESH_MS) return Promise.resolve(pick.value);
148 if (pick.reading !== undefined) return pick.reading;
149 const started = pick.epoch;
150 const p = findArcadeRepo(app)
151 .then((value) => {
152 pick.value = value;
153 pick.at = Date.now();
154 pick.readEpoch = started;
155 return value;
156 })
157 .finally(() => {
158 if (pick.reading === p) pick.reading = undefined;
159 });
160 pick.reading = p;
161 return p;
162 }
163
164 /** The Arcade's display repository as last read, for a reader that cannot wait; a stale pick is read again behind it. */
165 export function arcadeRepoKnown(app: App): Repo | undefined {
166 const pick = knownFor(app).arcade;
167 if (pick.readEpoch !== pick.epoch || Date.now() - pick.at > FRESH_MS) void arcadeRepoOf(app).catch(() => undefined);
168 return pick.value;
169 }
170
171 async function findArcadeRepo(app: App): Promise<Repo | undefined> {
172 const repos = listAdminOwnedRepos(app.db);
173 const read = await inTurn(repos, (repo) => displayOf(app, repo));
174 const named = repos.filter((_, i) => {
175 const config = read[i];
176 return config?.ok === true && config.value.title === ARCADE_TITLE;
177 });
178 if (named.length <= 1) return named[0];
179 const dates = repoDatesFor(app.db, named.map((r) => r.id));
180 const when = (r: Repo): string => {
181 const d = dates.get(r.id);
182 return d?.pushedAt ?? d?.createdAt ?? "";
183 };
184 // Newest first; a tie goes to the later id (ids are monotonic).
185 return [...named].sort((a, b) => when(b).localeCompare(when(a)) || b.id.localeCompare(a.id))[0];
186 }
187
188 /** How many of `repo`'s pins are in the showcase, as last read. */
189 export function showcasedIn(app: App, repo: Repo): Promise<number> {
190 return knownFor(app).showcased.get(repo.id, async () => {
191 const pins = await listBuildPins(repoDir(app.reposRoot, repo.ownerName, repo.name));
192 return pins.filter((p) => p.demo?.showcase === true).length;
193 });
194 }
195
196 /**
197 * How many demos a display shows: every showcased pin of its sources.
198 * Showcase publishes a demo (`items.ts`), so the count is the same for every
199 * viewer who reads the display. A display that chooses its `builds` counts
200 * what it shows a stranger: its showcased builds, and those in public
201 * repositories.
202 */
203 async function demoCount(app: App, display: Repo, config: DisplayConfig): Promise<number> {
204 const counted = await knownFor(app).counts.get(display.id, async () => {
205 if (config.builds !== undefined) {
206 const items = await displayItems(app, display, config, undefined);
207 return { n: items.length, sources: [...new Set(items.map((i) => i.repo.id))] };
208 }
209 const sources = sourceRepos(app, config);
210 const each = await Promise.all(sources.map((r) => showcasedIn(app, r)));
211 return { n: each.reduce((a, b) => a + b, 0), sources: sources.map((r) => r.id) };
212 });
213 return counted.n;
214 }
215
216 /** `fn` over `items`, `READS_AT_ONCE` at a time. */
217 async function inTurn<T, U>(items: readonly T[], fn: (item: T) => Promise<U>): Promise<U[]> {
218 const out: U[] = new Array<U>(items.length);
219 let next = 0;
220 const worker = async () => {
221 for (let i = next++; i < items.length; i = next++) out[i] = await fn(items[i]!);
222 };
223 await Promise.all(Array.from({ length: Math.min(READS_AT_ONCE, items.length) }, worker));
224 return out;
225 }
226
227 export interface ListedDisplay {
228 readonly repo: Repo;
229 /** The display's file, read: an error when it does not parse. */
230 readonly config: Result<DisplayConfig, string>;
231 /** Showcased demos on it; undefined when its file is bad. */
232 readonly demos: number | undefined;
233 }
234
235 /** Every repository with a display that `viewer` may read, by owner and name. */
236 export async function listedDisplays(app: App, viewer: User | undefined): Promise<ListedDisplay[]> {
237 const all = listEveryRepo(app.db);
238 const read = await inTurn(all, (repo) => displayOf(app, repo));
239 const having = all.flatMap((repo, i) => {
240 const config = read[i];
241 return config === undefined ? [] : [{ repo, config }];
242 });
243 const readable = readableRepoIds(app.db, viewer, having.map((d) => d.repo.id));
244 const shown = having.filter((d) => readable.has(d.repo.id));
245 const counts = await inTurn(shown, (d) => (d.config.ok ? demoCount(app, d.repo, d.config.value) : Promise.resolve(undefined)));
246 return shown.map((d, i) => ({ ...d, demos: counts[i] }));
247 }
248
249 /** Of `repos`, those with no `display.json` on their default branch. */
250 export async function withoutDisplay(app: App, repos: readonly Repo[]): Promise<Repo[]> {
251 const read = await inTurn(repos, (repo) => displayOf(app, repo));
252 return repos.filter((_, i) => read[i] === undefined);
253 }
src/display/selections.ts added +58
@@ -0,0 +1,58 @@
1 /**
2 * The selections a build can be in, as one viewer sees them: Showcased
3 * (the pin's own trailer, `git/builds.ts`) and every display the viewer
4 * reads, each holding its `builds` or, without them, today's showcased
5 * rule (`items.ts`). Showcased is one selection among the displays'
6 * (records/design/1101-forgej-displays/LAYOUT.md, point 2).
7 */
8
9 import type { App } from "../app.ts";
10 import { canWrite, type Repo } from "../core/repos.ts";
11 import type { User } from "../core/users.ts";
12 import { selectionKeys } from "./items.ts";
13 import { listedDisplays } from "./known.ts";
14
15 /** One display, as a selection of builds. */
16 export interface DisplaySelection {
17 /** `owner/name` of the display's repository: the selection's id in a URL. */
18 readonly id: string;
19 readonly repo: Repo;
20 readonly title: string;
21 /** Its builds, in order, as the viewer may name them. */
22 readonly keys: readonly string[];
23 /** Whether the file names its builds; without them the showcased rule holds until the first save. */
24 readonly chosen: boolean;
25 /** Whether the viewer may change it: a person who writes its repository. */
26 readonly writes: boolean;
27 }
28
29 /** The id the Showcased selection goes by in a URL. */
30 export const SHOWCASED = "showcased";
31
32 /** Whether `user` may change a display in `repo`: a person who writes it, never an agent. */
33 export const writesDisplay = (app: App, repo: Repo, user: User | undefined): boolean =>
34 user !== undefined && user.kind !== "agent" && canWrite(app.db, repo, user);
35
36 /** Every display `viewer` reads whose file parses, as a selection, by title. */
37 export async function displaySelections(app: App, viewer: User | undefined): Promise<DisplaySelection[]> {
38 const listed = await listedDisplays(app, viewer);
39 const out: DisplaySelection[] = [];
40 for (const d of listed) {
41 if (!d.config.ok) continue;
42 const config = d.config.value;
43 out.push({
44 id: `${d.repo.ownerName}/${d.repo.name}`,
45 repo: d.repo,
46 title: config.title,
47 keys: await selectionKeys(app, config, viewer),
48 chosen: config.builds !== undefined,
49 writes: writesDisplay(app, d.repo, viewer),
50 });
51 }
52 return out.sort((a, b) => (a.title.toLowerCase() < b.title.toLowerCase() ? -1 : a.title.toLowerCase() > b.title.toLowerCase() ? 1 : a.id < b.id ? -1 : 1));
53 }
54
55 /** The names of the selections a build is in: Showcased first, then each display. */
56 export function selectionsHolding(key: string, showcased: boolean, displays: readonly DisplaySelection[]): string[] {
57 return [...(showcased ? ["Showcased"] : []), ...displays.filter((d) => d.keys.includes(key)).map((d) => d.title)];
58 }
src/git/builds.ts +13
@@ -66,6 +66,19 @@ export interface Demo {
6666 readonly cover?: string | undefined;
6767 }
6868
69 /**
70 * How a demo opens: its pin's `Launch:`, else the repository's
71 * `app.launch`, else `file` when the app names pages to play and `env`
72 * when it names none.
73 */
74 export function launchOf(
75 demo: Pick<Demo, "launch"> | undefined,
76 appLaunch: Launch | undefined,
77 pages: readonly string[],
78 ): Launch {
79 return demo?.launch ?? appLaunch ?? (pages.length > 0 ? "file" : "env");
80 }
81
6982 export const COVERS_PREFIX = "refs/covers/";
7083
7184 /** A demo fact: one plain line, at most `max` characters. */
test/display-model.test.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * The display model: a display.json reads into a config, a malformed one is
3 * refused with a named error, items order by their list, selections name the
4 * displays holding a build, and an empty display compiles to an empty shelf.
5 */
6 import { describe, expect, test } from "bun:test";
7 import { shelfOf } from "../src/arcade/shelf.ts";
8 import { parseDisplay } from "../src/display/config.ts";
9 import { orderItems } from "../src/display/items.ts";
10 import { selectionsHolding, type DisplaySelection } from "../src/display/selections.ts";
11
12 const good = {
13 title: "Arcade",
14 layout: "shelf",
15 sources: ["jmoran", "common-arcade/kart-live"],
16 order: ["common-arcade/kart-live:fiesta-1.0"],
17 demos: { "jmoran/nova-moves:iter-5-brawl": { launch: "file" } },
18 shelf: { producer: "jbox" },
19 builds: ["jmoran/nova:b", "jmoran/nova:a"],
20 };
21
22 const refused = (value: unknown): string => {
23 const parsed = parseDisplay(typeof value === "string" ? value : JSON.stringify(value));
24 expect(parsed.ok).toBe(false);
25 return parsed.ok ? "" : parsed.error;
26 };
27
28 describe("display.json", () => {
29 test("a good file reads into a config", () => {
30 const parsed = parseDisplay(JSON.stringify(good));
31 if (!parsed.ok) throw new Error(parsed.error);
32 expect(parsed.value.title).toBe("Arcade");
33 expect(parsed.value.layout).toBe("shelf");
34 expect(parsed.value.sources).toEqual([
35 { kind: "owner", owner: "jmoran" },
36 { kind: "repo", owner: "common-arcade", name: "kart-live" },
37 ]);
38 expect(parsed.value.demos.get("jmoran/nova-moves:iter-5-brawl")).toEqual({ launch: "file" });
39 expect(parsed.value.builds).toEqual(["jmoran/nova:b", "jmoran/nova:a"]);
40 });
41
42 test("a malformed one is refused with a named error", () => {
43 expect(refused("{ nope")).toStartWith("display.json: not valid JSON");
44 expect(refused([])).toBe("display.json: must be a JSON object");
45 expect(refused({ ...good, layout: "carousel" })).toBe("display.json: layout must be shelf, grid or graph");
46 expect(refused({ ...good, title: "" })).toContain("title is required");
47 expect(refused({ ...good, sources: ["--upload-pack"] })).toContain("is not an owner or owner/name");
48 expect(refused({ ...good, builds: ["a/b:c", "a/b:c"] })).toBe("display.json: builds names a/b:c twice");
49 expect(refused({ ...good, producer: "jbox" })).toBe('display.json: unknown key "producer"');
50 });
51 });
52
53 describe("items and selections", () => {
54 test("named demos come first, then the rest oldest pinned first", () => {
55 const item = (key: string, at: string) => ({ key, pin: { pinnedAt: at } });
56 const items = [item("a/x:new", "2026-10-03"), item("a/x:old", "2026-10-01"), item("a/x:mid", "2026-10-02"), item("a/x:hand", "2026-10-04")];
57 expect(orderItems(items, ["a/x:hand", "a/x:gone", "a/x:mid"]).map((i) => i.key)).toEqual(["a/x:hand", "a/x:mid", "a/x:old", "a/x:new"]);
58 });
59
60 test("a build is in Showcased and each display that lists it", () => {
61 const display = (title: string, keys: string[]) => ({ id: title, title, keys }) as unknown as DisplaySelection;
62 const displays = [display("Shelf", ["a/x:one"]), display("Other", ["a/x:two"])];
63 expect(selectionsHolding("a/x:one", true, displays)).toEqual(["Showcased", "Shelf"]);
64 expect(selectionsHolding("a/x:two", false, displays)).toEqual(["Other"]);
65 });
66
67 test("an empty display compiles to an empty shelf", () => {
68 const shelf = shelfOf([], "jbox");
69 expect(shelf.games).toEqual([]);
70 expect(shelf.entries.size).toBe(0);
71 });
72 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs