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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9329-jay-moran-s-display-model-is-on-main-a-d → main 3 commits fork
11 files
changed +1065
−0
scripts/hollow-allow.ts +10
| @@ -19,4 +19,14 @@ export type Allowed = { | ||
| 19 | 19 | }; |
| 20 | 20 | |
| 21 | 21 | 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 | }, | |
| 22 | 32 | ]; |
scripts/unreached-allow.json +6
| @@ -1,9 +1,15 @@ | ||
| 1 | 1 | [ |
| 2 | "src/actions/pin_demos.ts", | |
| 2 | 3 | "src/apps/cloud.ts", |
| 3 | 4 | "src/arcade/cover.ts", |
| 5 | "src/arcade/shelf.ts", | |
| 4 | 6 | "src/boards/machine.ts", |
| 5 | 7 | "src/crew/governor_log.ts", |
| 6 | 8 | "src/crew/tries.ts", |
| 9 | "src/display/config.ts", | |
| 10 | "src/display/items.ts", | |
| 11 | "src/display/known.ts", | |
| 12 | "src/display/selections.ts", | |
| 7 | 13 | "src/flow/alarm_drift.ts", |
| 8 | 14 | "src/flow/alarm_unproposed.ts", |
| 9 | 15 | "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"; | ||
| 15 | 15 | import { parseService, type ServiceSpec } from "./service.ts"; |
| 16 | 16 | import type { DeclaredLimits } from "./contain.ts"; |
| 17 | 17 | import { isPublicAddress } from "./egress.ts"; |
| 18 | import { isLaunch, LAUNCHES, type Launch } from "../git/builds.ts"; | |
| 18 | 19 | |
| 19 | 20 | export interface AppManifest { |
| 20 | 21 | /** The command that serves on `$PORT`. Run through `sh -c`, `exec`-ed. */ |
| @@ -46,6 +47,10 @@ export interface AppManifest { | ||
| 46 | 47 | * or empty is none, exactly as before this field existed. |
| 47 | 48 | */ |
| 48 | 49 | 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; | |
| 49 | 54 | /** |
| 50 | 55 | * Set when `app.kind` is `"service"`: declared TCP ports, always on, |
| 51 | 56 | * a placement preference and synced volumes. `null` for an ordinary |
| @@ -151,6 +156,11 @@ export function parseManifest( | ||
| 151 | 156 | if (!looks.ok) return looks; |
| 152 | 157 | const looksAsOwner = looksAsOwnerField(app, looks.value); |
| 153 | 158 | 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(", ")}`); | |
| 154 | 164 | const egress = egressField(parsed.value); |
| 155 | 165 | if (!egress.ok) return egress; |
| 156 | 166 | const limits = limitsField(app, "app"); |
| @@ -184,6 +194,8 @@ export function parseManifest( | ||
| 184 | 194 | tty: tty.value || null, |
| 185 | 195 | looks: looks.value, |
| 186 | 196 | looksAsOwner: looksAsOwner.value, |
| 197 | pages: pages.value, | |
| 198 | ...(launch === undefined ? {} : { launch }), | |
| 187 | 199 | service, |
| 188 | 200 | egress: egress.value, |
| 189 | 201 | limits: limits.value, |
| @@ -304,6 +316,23 @@ function looksAsOwnerField( | ||
| 304 | 316 | return ok(paths.value); |
| 305 | 317 | } |
| 306 | 318 | |
| 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 | ||
| 307 | 336 | /** A list of page paths, as `app.looks` and `app.looksAsOwner` take them. */ |
| 308 | 337 | function pathList( |
| 309 | 338 | 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 { | ||
| 66 | 66 | readonly cover?: string | undefined; |
| 67 | 67 | } |
| 68 | 68 | |
| 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 | ||
| 69 | 82 | export const COVERS_PREFIX = "refs/covers/"; |
| 70 | 83 | |
| 71 | 84 | /** 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 | }); |
new above
offline