@atelic-action/ui 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * The deploy proof for a private artifact: the wall answers before any link
3
+ * goes out (the practice's FOOTPRINT.md, Privacy). It asks the live host
4
+ * for each document with no cookie and passes only when every one answers
5
+ * with the wall's redirect to /login. A document that answers 200 is
6
+ * readable by anyone holding the link, and a 5xx is a host that is down
7
+ * rather than walled; both fail.
8
+ *
9
+ * The documents are /writeup, plus /report and /proposal when this repo
10
+ * carries those routes. The wall holds files too (needsSession in @atelic-action/ui/gate), so it
11
+ * also asks for the printed copy at /writeup.pdf, the first real screenshot
12
+ * under public/images/, and, when the repo has been built, the first chunk
13
+ * under /assets/doc/, where a document's content compiles to. Each of those
14
+ * must answer with the same redirect: the wall holds a path whether or not
15
+ * the file is there, so a 404 means the wall never ran for it.
16
+ *
17
+ * One thing must stay open on purpose: when public/brand/ has a file, it
18
+ * asks for the first and requires a 200. Brand marks live there because the
19
+ * header draws them on every page, the login page included, and a mark the
20
+ * wall held would leave the login page asking for a file it cannot have.
21
+ *
22
+ * Then it proves the two sign in functions answer, because a wall whose
23
+ * functions cannot load holds every page and can send no link. It posts an
24
+ * address on no allowlist to /api/auth/request-link, so nothing is ever
25
+ * sent, and requires the 200 every address gets. It asks
26
+ * /api/auth/verify for a token that is not one and requires the function's
27
+ * own 302 back to /login?e=expired. A 5xx from either is a function that
28
+ * failed to load: on 2026-10-01 both failed on an extensionless import.
29
+ *
30
+ * Usage: atelic-check-wall <host> [path ...], or `bun run check:wall <host>`
31
+ * in a site, from the site's root
32
+ * bun run check:wall summit.atelic.me
33
+ * bun run check:wall https://summit.atelic.me /writeup /report
34
+ * The sign in functions are asked only when no path is named.
35
+ * Exits 1 unless every path answers as it must.
36
+ */
37
+ import { existsSync, readdirSync } from "node:fs";
38
+ import { join } from "node:path";
39
+
40
+ /**
41
+ * One thing to ask the host for. Every probe but a sign in one is a GET with
42
+ * no body.
43
+ */
44
+ export type Probe =
45
+ | {
46
+ path: string;
47
+ /**
48
+ * A page the wall must stand in front of, a file it must hold back, or a
49
+ * brand mark it must leave open.
50
+ */
51
+ kind: "document" | "file" | "open";
52
+ }
53
+ | {
54
+ path: string;
55
+ /**
56
+ * A sign in function that must answer for itself: the request for a
57
+ * link is a POST that must answer 200, and the verify is a GET that
58
+ * must answer its own redirect back to /login.
59
+ */
60
+ kind: "signin";
61
+ method: "GET" | "POST";
62
+ /** Sent as JSON with a POST. */
63
+ body?: Record<string, string>;
64
+ };
65
+
66
+ /** The shape of an answer the classifier reads; a fetch Response fits it. */
67
+ export interface WallAnswer {
68
+ status: number;
69
+ headers: { get(name: string): string | null };
70
+ }
71
+
72
+ export interface WallVerdict {
73
+ ok: boolean;
74
+ reason: string;
75
+ }
76
+
77
+ /** A bare host or a full URL, as its origin. */
78
+ export function toOrigin(host: string): string {
79
+ const withScheme = /^https?:\/\//.test(host) ? host : `https://${host}`;
80
+ return new URL(withScheme).origin;
81
+ }
82
+
83
+ export interface WallTargets {
84
+ /** Whether this repo carries a route, by its file name in src/routes/. */
85
+ hasRoute: (name: string) => boolean;
86
+ /** The first file under public/images/, as a path from the host root. */
87
+ image?: string;
88
+ /** The first chunk under dist/client/assets/doc/, when the repo is built. */
89
+ docChunk?: string;
90
+ /** The first file under public/brand/, which must stay open. */
91
+ brand?: string;
92
+ }
93
+
94
+ /** The paths to ask for: the documents this repo carries, the held files, then the open mark. */
95
+ export function wallProbes({ hasRoute, image, docChunk, brand }: WallTargets): Probe[] {
96
+ const probes: Probe[] = [{ path: "/writeup", kind: "document" }];
97
+ if (hasRoute("report")) probes.push({ path: "/report", kind: "document" });
98
+ if (hasRoute("proposal")) probes.push({ path: "/proposal", kind: "document" });
99
+ probes.push({ path: "/writeup.pdf", kind: "file" });
100
+ if (image) probes.push({ path: image, kind: "file" });
101
+ if (docChunk) probes.push({ path: docChunk, kind: "file" });
102
+ if (brand) probes.push({ path: brand, kind: "open" });
103
+ return probes;
104
+ }
105
+
106
+ /** The address the sign in request posts: on no allowlist, so no link is ever sent. */
107
+ export const CHECK_EMAIL = "wall-check@example.com";
108
+
109
+ /** The two sign in functions, asked by default after the wall's own probes. */
110
+ export function signinProbes(): Probe[] {
111
+ return [
112
+ {
113
+ path: "/api/auth/request-link",
114
+ kind: "signin",
115
+ method: "POST",
116
+ body: { email: CHECK_EMAIL },
117
+ },
118
+ { path: "/api/auth/verify?token=not-a-token", kind: "signin", method: "GET" },
119
+ ];
120
+ }
121
+
122
+ /** The first file in a directory, sorted, as `prefix/name`; none if it is empty or absent. */
123
+ export function firstFile(dir: string, prefix: string): string | undefined {
124
+ if (!existsSync(dir)) return undefined;
125
+ const name = readdirSync(dir, { withFileTypes: true })
126
+ .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
127
+ .map((entry) => entry.name)
128
+ .sort()[0];
129
+ return name ? `${prefix}/${encodeURIComponent(name)}` : undefined;
130
+ }
131
+
132
+ const HOST_DOWN =
133
+ "the host is failing, not walled. On 2026-09-09 a new artifact host answered MIDDLEWARE_INVOCATION_FAILED on every page (ATE-537): verify the host answers before any link goes out.";
134
+
135
+ /** Whether a brand mark answered open, as it must. */
136
+ function classifyOpen(answer: WallAnswer, origin: string): WallVerdict {
137
+ const { status } = answer;
138
+ if (status === 200) {
139
+ return { ok: true, reason: `${status}: open on purpose, a brand mark the header draws` };
140
+ }
141
+ if (status >= 300 && status < 400) {
142
+ const location = answer.headers.get("location");
143
+ const toLogin = location && new URL(location, origin).pathname === "/login";
144
+ return {
145
+ ok: false,
146
+ reason: toLogin
147
+ ? `${status} to /login: the wall holds a brand mark, so the login page asks for a file it cannot have. Brand marks stay open under /brand/ (needsSession in @atelic-action/ui/gate)`
148
+ : `${status} to ${location ?? "nowhere"}: a brand mark should answer 200`,
149
+ };
150
+ }
151
+ if (status > 200 && status < 300) {
152
+ return {
153
+ ok: false,
154
+ reason: `${status}: a brand mark should answer 200 with the file, not another success code`,
155
+ };
156
+ }
157
+ if (status === 404) {
158
+ return { ok: false, reason: "404: the brand mark is not on the host. Check the deploy" };
159
+ }
160
+ if (status >= 500) return { ok: false, reason: `${status}: ${HOST_DOWN}` };
161
+ if (status === 0) return { ok: false, reason: `no answer: ${HOST_DOWN}` };
162
+ return { ok: false, reason: `${status}: a brand mark should answer 200` };
163
+ }
164
+
165
+ const FUNCTION_FAILED =
166
+ "the sign in function failed to load. On 2026-10-01 the first host with the wall armed answered FUNCTION_INVOCATION_FAILED here (ERR_MODULE_NOT_FOUND on an extensionless import), so the wall held every page and could send no link: the two files under api/auth/ must each be a re export from @atelic-action/ui, whose gate ships compiled for this reason";
167
+
168
+ /** Whether a sign in function answered for itself, as it must. */
169
+ function classifySignin(
170
+ probe: Extract<Probe, { kind: "signin" }>,
171
+ answer: WallAnswer,
172
+ origin: string,
173
+ ): WallVerdict {
174
+ const { status } = answer;
175
+ if (status >= 500) return { ok: false, reason: `${status}: ${FUNCTION_FAILED}` };
176
+ if (status === 0) return { ok: false, reason: `no answer: ${HOST_DOWN}` };
177
+ if (status === 401 || status === 403) {
178
+ return {
179
+ ok: false,
180
+ reason: `${status}: Vercel Deployment Protection or another lock answered, not the function. Turn Deployment Protection off (DEPLOY.md)`,
181
+ };
182
+ }
183
+ if (status === 404) {
184
+ return { ok: false, reason: "404: the sign in function is not on the host. Check the deploy" };
185
+ }
186
+ const location = answer.headers.get("location");
187
+ if (probe.method === "POST") {
188
+ if (status === 200) {
189
+ return { ok: true, reason: "200: the request for a link answered, sending nothing" };
190
+ }
191
+ return {
192
+ ok: false,
193
+ reason: `${status}${location ? ` to ${location}` : ""}: the request for a link answers 200 to every address`,
194
+ };
195
+ }
196
+ // A token that is not one gets the function's own redirect, marked expired;
197
+ // the wall's redirect carries ?next= instead, so the two cannot be mistaken.
198
+ const target = location ? new URL(location, origin) : undefined;
199
+ if (
200
+ status === 302 &&
201
+ target?.pathname === "/login" &&
202
+ target.searchParams.get("e") === "expired"
203
+ ) {
204
+ return { ok: true, reason: "302 to /login?e=expired: the verify answered a bad token" };
205
+ }
206
+ return {
207
+ ok: false,
208
+ reason: `${status}${location ? ` to ${location}` : ""}: the verify answers a bad token with a 302 to /login?e=expired`,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Whether one answer is the wall (or, for a brand mark, open, or for a sign
214
+ * in function, its own answer), and if not, what it is instead.
215
+ */
216
+ export function classifyWall(probe: Probe, answer: WallAnswer, origin: string): WallVerdict {
217
+ if (probe.kind === "open") return classifyOpen(answer, origin);
218
+ if (probe.kind === "signin") return classifySignin(probe, answer, origin);
219
+ const { status } = answer;
220
+ if (status >= 300 && status < 400) {
221
+ const location = answer.headers.get("location");
222
+ if (location && new URL(location, origin).pathname === "/login") {
223
+ return { ok: true, reason: `${status} to /login: the wall answered` };
224
+ }
225
+ return {
226
+ ok: false,
227
+ reason: `${status} to ${location ?? "nowhere"}, not the wall's /login: something other than the wall answered`,
228
+ };
229
+ }
230
+ if (status >= 200 && status < 300) {
231
+ const type = answer.headers.get("content-type") ?? "no content type";
232
+ return probe.kind === "file"
233
+ ? {
234
+ ok: false,
235
+ reason: `${status} (${type}): the file is served outside the wall, so anyone with its address can open it. Check the middleware is the current one (needsSession in @atelic-action/ui/gate) and that the wall is armed`,
236
+ }
237
+ : {
238
+ ok: false,
239
+ reason: `${status} (${type}): the document is served with no wall, so anyone with the link can read it. Arm the wall (ACCESS-GATE.md), redeploy, and run this again`,
240
+ };
241
+ }
242
+ if (status === 404) {
243
+ return probe.kind === "file"
244
+ ? {
245
+ ok: false,
246
+ reason:
247
+ "404: the wall never ran for this file. It holds a path whether or not the file is there, so a miss means the middleware let the request through",
248
+ }
249
+ : {
250
+ ok: false,
251
+ reason: "404: the host does not serve this page. Check the deploy and the prerender list",
252
+ };
253
+ }
254
+ if (status === 401 || status === 403) {
255
+ return {
256
+ ok: false,
257
+ reason: `${status}: Vercel Deployment Protection or another lock answered, not the wall. Turn Deployment Protection off (DEPLOY.md) so the wall is what a reader meets`,
258
+ };
259
+ }
260
+ if (status >= 500) return { ok: false, reason: `${status}: ${HOST_DOWN}` };
261
+ if (status === 0) return { ok: false, reason: `no answer: ${HOST_DOWN}` };
262
+ return { ok: false, reason: `${status}: an answer the wall never gives` };
263
+ }
264
+
265
+ export type Fetcher = (url: string, init: RequestInit) => Promise<WallAnswer>;
266
+
267
+ /** The request for one probe: a GET unless a sign in probe says otherwise, with no cookie. */
268
+ export function probeRequest(probe: Probe): RequestInit {
269
+ const headers: Record<string, string> = {
270
+ "user-agent": "Mozilla/5.0 (wall-check; +https://atelic.me)",
271
+ };
272
+ const init: RequestInit = {
273
+ method: probe.kind === "signin" ? probe.method : "GET",
274
+ redirect: "manual",
275
+ headers,
276
+ signal: AbortSignal.timeout(15000),
277
+ };
278
+ if (probe.kind === "signin" && probe.body) {
279
+ headers["content-type"] = "application/json";
280
+ init.body = JSON.stringify(probe.body);
281
+ }
282
+ return init;
283
+ }
284
+
285
+ /** Asks for every probe with no cookie and never follows a redirect. */
286
+ export async function checkWall(origin: string, probes: Probe[], fetcher: Fetcher) {
287
+ const results: Array<Probe & WallVerdict> = [];
288
+ for (const probe of probes) {
289
+ let answer: WallAnswer;
290
+ try {
291
+ answer = await fetcher(`${origin}${probe.path}`, probeRequest(probe));
292
+ } catch {
293
+ answer = { status: 0, headers: { get: () => null } };
294
+ }
295
+ results.push({ ...probe, ...classifyWall(probe, answer, origin) });
296
+ }
297
+ return results;
298
+ }
299
+
300
+ /**
301
+ * The command. The routes, the images, the brand marks, and the build it
302
+ * reads are the site's, found from the working directory.
303
+ */
304
+ export async function main(args: string[], root: string = process.cwd()): Promise<void> {
305
+ const [host, ...paths] = args;
306
+ if (!host) {
307
+ console.error("usage: bun run check:wall <host> [path ...]");
308
+ process.exit(2);
309
+ }
310
+ const origin = toOrigin(host);
311
+ const probes: Probe[] = paths.length
312
+ ? paths.map((path) => ({
313
+ path,
314
+ kind: path.startsWith("/brand/")
315
+ ? "open"
316
+ : /\.[a-z0-9]+$/i.test(path)
317
+ ? "file"
318
+ : "document",
319
+ }))
320
+ : [
321
+ ...wallProbes({
322
+ hasRoute: (name) => existsSync(join(root, "src", "routes", `${name}.tsx`)),
323
+ image: firstFile(join(root, "public", "images"), "/images"),
324
+ docChunk: firstFile(join(root, "dist", "client", "assets", "doc"), "/assets/doc"),
325
+ brand: firstFile(join(root, "public", "brand"), "/brand"),
326
+ }),
327
+ ...signinProbes(),
328
+ ];
329
+ if (!probes.some((probe) => probe.path.startsWith("/images/"))) {
330
+ console.warn("[wall] no file under public/images/ to ask for; the screenshots go unproven");
331
+ }
332
+ if (!probes.some((probe) => probe.path.startsWith("/assets/doc/"))) {
333
+ console.warn(
334
+ "[wall] no build in dist/client/assets/doc/; run bun run build first to prove the content chunks",
335
+ );
336
+ }
337
+ console.log(`[wall] asking ${origin} for ${probes.length} paths with no cookie`);
338
+ const results = await checkWall(origin, probes, (url, init) => fetch(url, init));
339
+ for (const result of results) {
340
+ const line = ` ${result.ok ? "ok" : "FAIL"} ${result.path}: ${result.reason}`;
341
+ if (result.ok) console.log(line);
342
+ else console.error(line);
343
+ }
344
+ const failed = results.filter((result) => !result.ok).length;
345
+ if (failed > 0) {
346
+ console.error(
347
+ `[wall] ${failed} of ${results.length} failed. No link goes out until this passes.`,
348
+ );
349
+ process.exit(1);
350
+ }
351
+ const signin = probes.some((probe) => probe.kind === "signin");
352
+ console.log(
353
+ signin
354
+ ? "[wall] the wall holds every document and file asked for, the brand mark is open, and the sign in functions answered"
355
+ : "[wall] the wall holds every document and file asked for, and the brand mark is open",
356
+ );
357
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Mint a named reader and print the two things that come of it: the line
3
+ * that goes in site.config.ts and the link that goes in the email. Every
4
+ * link sent to a named person carries their token, so their open lands on
5
+ * their contact in HubSpot (DEPLOY.md, Who Opened It).
6
+ *
7
+ * The config line holds the reader sealed: a digest of the token and the
8
+ * email encrypted under it. The token itself appears only in the link, so
9
+ * keep the link; it cannot be printed again.
10
+ *
11
+ * A writeup's reader also gets the printed copy's QR: public/images/qr.png
12
+ * is written to carry the same link, so a scan of the sheet lands on that
13
+ * person's contact as an emailed open does. Mint the reader before printing.
14
+ *
15
+ * The sheet belongs to the first reader named. A later reader never
16
+ * repoints it: with a reader already in analytics.identify the QR is left
17
+ * alone unless --qr asks for it.
18
+ *
19
+ * Usage: atelic-reader-link <email> [path] [--token <existing token>] [--qr | --no-qr],
20
+ * or `bun run reader <email>` in a site, from the site's root.
21
+ * path defaults to /writeup. --token seals an email under a token that is
22
+ * already in a sent link, so that link keeps working. --qr writes the QR
23
+ * for this reader even when another is already named; --no-qr never writes it.
24
+ */
25
+ import { resolve } from "node:path";
26
+ import QRCode from "qrcode";
27
+ import { mintToken, readerLink, sealReader } from "../artifact/readers";
28
+ import { loadSite } from "./site-config";
29
+
30
+ export const USAGE =
31
+ "Usage: bun run reader <email> [path] [--token <existing token>] [--qr | --no-qr]";
32
+
33
+ /** What the command line asked for. */
34
+ export interface ReaderArgs {
35
+ email: string;
36
+ /** The page the link opens, always with one leading slash and none trailing. */
37
+ path: string;
38
+ /** A token already in a sent link, when --token gave one. */
39
+ token?: string;
40
+ /** --qr: write the QR for this reader even when another is already named. */
41
+ forceQr: boolean;
42
+ /** --no-qr: never write the QR. */
43
+ noQr: boolean;
44
+ }
45
+
46
+ /** Reads the command line; undefined when it does not name a reader, which is a usage error. */
47
+ export function parseReaderArgs(flags: string[]): ReaderArgs | undefined {
48
+ const noQr = flags.includes("--no-qr");
49
+ const forceQr = flags.includes("--qr");
50
+ const args = flags.filter((flag) => flag !== "--no-qr" && flag !== "--qr");
51
+ const at = args.indexOf("--token");
52
+ const given = at === -1 ? undefined : args[at + 1];
53
+ const rest = at === -1 ? args : args.filter((_, index) => index !== at && index !== at + 1);
54
+ const [email, givenPath = "/writeup"] = rest;
55
+ // "writeup", "/writeup", and "/writeup/" are one page.
56
+ const path = `/${givenPath.replace(/^\/+|\/+$/g, "")}`;
57
+
58
+ if (!email?.includes("@") || (at !== -1 && !given)) return undefined;
59
+ return { email, path, token: given, forceQr, noQr };
60
+ }
61
+
62
+ /**
63
+ * The command. The config it reads and the QR it writes are the site's,
64
+ * found from the working directory.
65
+ */
66
+ export async function main(flags: string[], cwd: string = process.cwd()): Promise<void> {
67
+ const parsed = parseReaderArgs(flags);
68
+ if (!parsed) {
69
+ console.error(USAGE);
70
+ process.exit(1);
71
+ }
72
+ const { email, path, forceQr, noQr } = parsed;
73
+ let site: Awaited<ReturnType<typeof loadSite>>;
74
+ try {
75
+ site = await loadSite(cwd);
76
+ } catch (error) {
77
+ console.error(`[reader] ${(error as Error).message}`);
78
+ process.exit(1);
79
+ }
80
+
81
+ const token = parsed.token ?? mintToken();
82
+ const reader = await sealReader(email, token);
83
+ console.log("Add to analytics.identify in src/site.config.ts:");
84
+ console.log(` { id: "${reader.id}", sealed: "${reader.sealed}" }, // ${email.split("@")[1]}`);
85
+ console.log("");
86
+ console.log("Send this link, and only to that person. Keep it: it cannot be printed again.");
87
+ const link = readerLink(site.url, token, path);
88
+ console.log(` ${link}`);
89
+
90
+ // The sheet's QR is the reader's link. A private artifact identifies by sign
91
+ // in and never by a token, so its QR stays on the bare host.
92
+ const named = site.analytics?.identify.length ?? 0;
93
+ const sheetIsFree = named === 0 || forceQr;
94
+ if (path === "/writeup" && !noQr && site.access === "public" && !sheetIsFree) {
95
+ console.log("");
96
+ console.log(
97
+ "Left public/images/qr.png alone: a reader is already named, and the sheet is theirs. Pass --qr to give the sheet to this reader.",
98
+ );
99
+ }
100
+ if (path === "/writeup" && !noQr && site.access === "public" && sheetIsFree) {
101
+ const qr = resolve(cwd, "public/images/qr.png");
102
+ await QRCode.toFile(qr, link, { width: 330, margin: 4, errorCorrectionLevel: "M" });
103
+ console.log("");
104
+ console.log(
105
+ "Wrote public/images/qr.png with that link. Print the sheet after this, never before.",
106
+ );
107
+ }
108
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Serves dist/client the way Vercel does for these sites: a file as is, a
3
+ * clean URL from its .html file or folder index, and anything else as
4
+ * 404.html with a real 404 status. Only the e2e suite uses it; production
5
+ * never runs a server. The build it serves is the site's, found from the
6
+ * working directory.
7
+ *
8
+ * Usage: atelic-serve-static (PORT sets the port, 4173 unless given)
9
+ */
10
+ import { existsSync, statSync } from "node:fs";
11
+ import { join, normalize } from "node:path";
12
+
13
+ /** The file a request path names under `root`, by Vercel's clean URL rules; none on a miss. */
14
+ export function resolveFile(root: string, pathname: string): string | undefined {
15
+ const clean = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, "");
16
+ const candidates = [
17
+ join(root, clean),
18
+ join(root, `${clean}.html`),
19
+ join(root, clean, "index.html"),
20
+ ];
21
+ return candidates.find(
22
+ (file) => file.startsWith(root) && existsSync(file) && statSync(file).isFile(),
23
+ );
24
+ }
25
+
26
+ /** The command: serves dist/client of the working directory until stopped. */
27
+ export function main(cwd: string = process.cwd()): void {
28
+ const root = join(cwd, "dist", "client");
29
+ const port = Number(process.env.PORT ?? 4173);
30
+
31
+ Bun.serve({
32
+ // Loopback only: this serves the build to the local browser under test and
33
+ // has no business answering anyone else on the network.
34
+ hostname: "127.0.0.1",
35
+ port,
36
+ fetch(request) {
37
+ const file = resolveFile(root, new URL(request.url).pathname);
38
+ if (file) return new Response(Bun.file(file));
39
+ return new Response(Bun.file(join(root, "404.html")), {
40
+ status: 404,
41
+ headers: { "content-type": "text/html; charset=utf-8" },
42
+ });
43
+ },
44
+ });
45
+
46
+ console.log(`serving ${root} on http://127.0.0.1:${port}`);
47
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Finds the site a command was run in. A command lives in the package, so
3
+ * it cannot import the site's config by a relative path as a script in the
4
+ * site once did: it resolves src/site.config.ts against the directory it
5
+ * was run from and imports that.
6
+ */
7
+ import { existsSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import type { ArtifactConfig } from "../artifact/artifactConfig";
11
+
12
+ /** Where a site keeps its config, from the site's root. */
13
+ export const SITE_CONFIG = "src/site.config.ts";
14
+
15
+ /** The absolute path of the config for the site rooted at `cwd`. */
16
+ export function siteConfigPath(cwd: string = process.cwd()): string {
17
+ return resolve(cwd, SITE_CONFIG);
18
+ }
19
+
20
+ /**
21
+ * The `site` export of the config in `cwd`. Throws one line naming the
22
+ * directory when there is no config there, or when it exports no `site`.
23
+ */
24
+ export async function loadSite(cwd: string = process.cwd()): Promise<ArtifactConfig> {
25
+ const path = siteConfigPath(cwd);
26
+ if (!existsSync(path)) {
27
+ throw new Error(`no ${SITE_CONFIG} in ${cwd}. Run this from the root of an artifact site.`);
28
+ }
29
+ const module = (await import(pathToFileURL(path).href)) as { site?: ArtifactConfig };
30
+ if (!module.site) {
31
+ throw new Error(`${SITE_CONFIG} in ${cwd} exports no \`site\`.`);
32
+ }
33
+ return module.site;
34
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Writes dist/client/health.json after the build: a shallow health endpoint
3
+ * for uptime monitors that also answers "which build is live?".
4
+ * Runs as the last step of `bun build`, from the site's root.
5
+ *
6
+ * Usage: atelic-stamp-health
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import { writeFileSync } from "node:fs";
10
+
11
+ function commitSha(): string {
12
+ // Vercel builds have no .git directory; the CLI/CI export the sha instead.
13
+ const fromEnv = process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.GITHUB_SHA;
14
+ if (fromEnv) return fromEnv.slice(0, 7);
15
+ const git = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" });
16
+ return git.status === 0 ? git.stdout.trim() : "unknown";
17
+ }
18
+
19
+ /** The command: stamps the build in the working directory. */
20
+ export function main(): void {
21
+ const health = {
22
+ status: "ok",
23
+ commit: commitSha(),
24
+ builtAt: new Date().toISOString(),
25
+ };
26
+
27
+ writeFileSync("dist/client/health.json", `${JSON.stringify(health, null, "\t")}\n`);
28
+ console.log(`[health] dist/client/health.json (${health.commit})`);
29
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The whole Vite configuration of an artifact site, as one call. Every site
3
+ * cut from the artifact template carried its own copy of this configuration;
4
+ * here it is written once, and a site's vite.config.ts names its pages:
5
+ *
6
+ * import { artifactConfig } from "@atelic-action/ui/vite";
7
+ *
8
+ * export default artifactConfig({ pages: ["/writeup", "/proposal", "/report"] });
9
+ *
10
+ * The site's own paths (the `@` alias to its src) are found from the
11
+ * directory Vite is run in, which is the site's root.
12
+ */
13
+ import { resolve } from "node:path";
14
+ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
15
+ import react from "@vitejs/plugin-react";
16
+ import { mergeConfig, type UserConfig } from "vite";
17
+ import { privateChunks } from "./private-chunks.js";
18
+
19
+ export interface ArtifactConfigOptions {
20
+ /**
21
+ * The path of each artifact page the site carries, such as "/writeup".
22
+ * The root and the 404 page are always prerendered and need no entry.
23
+ */
24
+ pages: readonly string[];
25
+ /**
26
+ * The escape hatch for a site with a real need of its own: merged over
27
+ * the result with Vite's `mergeConfig`, so a site adds a setting without
28
+ * going back to a configuration written by hand.
29
+ */
30
+ extend?: UserConfig;
31
+ }
32
+
33
+ /** One entry of the prerender list. */
34
+ export interface ArtifactPage {
35
+ path: string;
36
+ prerender: { enabled: true };
37
+ }
38
+
39
+ /** The prerender list for a site: the root, each page it names, then the 404. */
40
+ export function artifactPages(pages: readonly string[]): ArtifactPage[] {
41
+ const named = pages.filter((path) => path !== "/" && path !== "/404");
42
+ return [
43
+ // The root holds no document: it sends the reader to /writeup
44
+ // (vercel.json does the same on the host), and its prerendered
45
+ // index.html is what a static preview serves at the bare host.
46
+ { path: "/", prerender: { enabled: true } },
47
+ ...named.map((path): ArtifactPage => ({ path, prerender: { enabled: true } })),
48
+ // The 404 page, rendered by the catch all route and copied to
49
+ // dist/client/404.html by the build for the host to serve on any miss.
50
+ { path: "/404", prerender: { enabled: true } },
51
+ ];
52
+ }
53
+
54
+ export function artifactConfig({ pages, extend }: ArtifactConfigOptions): UserConfig {
55
+ const config: UserConfig = {
56
+ // Every stylesheet rides in the one base sheet the root route links
57
+ // (src/styles/base.css), the documents' own included, since they come from
58
+ // @atelic-action/ui. Nothing imports CSS from a component, so there is no
59
+ // component bundle to fold: `build.cssCodeSplit: false`, which sat here to
60
+ // fold it, now fails the build, because TanStack Start looks for the
61
+ // `style.css` that setting emits and none is written. A site that gives a
62
+ // component its own stylesheet again restores the setting through `extend`.
63
+ plugins: [
64
+ tanstackStart({
65
+ prerender: {
66
+ enabled: true,
67
+ crawlLinks: true,
68
+ // The writeup links each screenshot crop to its full image, and
69
+ // the crawler follows every href it finds: without this filter it
70
+ // prerenders /images/<shot>.png as an HTML page and writes that
71
+ // over the real file in dist/client (found on a client's writeup,
72
+ // 2026-09-24).
73
+ filter: ({ path }) => !path.startsWith("/images/"),
74
+ },
75
+ // Nothing links to an artifact page, so the crawler would miss every
76
+ // one of them; each is listed by the site to be prerendered at all. A
77
+ // site names the pages it uses and no others.
78
+ pages: artifactPages(pages),
79
+ // No sitemap. A sitemap is a page asking to be crawled, and every
80
+ // page here is a document prepared for one business; public/robots.txt
81
+ // disallows the whole host to match.
82
+ sitemap: { enabled: false },
83
+ }),
84
+ react(),
85
+ // A document's content builds into /assets/doc/, which the access wall
86
+ // holds back, and the build fails if any lands where the login page
87
+ // can load it (privateChunks beside this file, needsSession in
88
+ // @atelic-action/ui/gate).
89
+ privateChunks(),
90
+ ],
91
+ resolve: {
92
+ alias: {
93
+ // The site's src, from the directory Vite is run in: this file lives
94
+ // in the package, so nothing here can be found relative to it.
95
+ "@": resolve(process.cwd(), "./src"),
96
+ },
97
+ },
98
+ // @atelic-action/ui ships TSX and CSS as written, so the prerender's server build
99
+ // has to compile it rather than hand it to Node as an external.
100
+ ssr: {
101
+ noExternal: ["@atelic-action/ui"],
102
+ },
103
+ };
104
+ return extend ? mergeConfig(config, extend) : config;
105
+ }