Skip to content

Commit 6b07186

Browse files
committed
fix(docs): keep showcase pages working without the share API
Showcase list, detail and replay pages resolved every record through an external Cloudflare Worker — at build time for the list and at runtime for detail/replay — so any Worker outage blanked the list and broke every detail page. Ship the 17 public shares as a committed snapshot instead, so all three routes render from the bundle and need no network at all. The read API client and the build-time fetch plugin have no callers left and are removed; `pnpm refresh:showcase-data` regenerates the snapshot on demand.
1 parent c2ad42e commit 6b07186

10 files changed

Lines changed: 552 additions & 212 deletions

File tree

multimodal/websites/docs/env.d.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1 @@
11
/// <reference types="@rspress/theme-default" />
2-
3-
// Virtual module for build-time injected showcase data
4-
declare module 'showcase-data' {
5-
import type { ApiShareItem } from './src/services/api';
6-
export const showcaseData: ApiShareItem[];
7-
export const lastUpdated: string;
8-
}

multimodal/websites/docs/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"scripts": {
66
"build": "rspress build",
77
"dev": "rspress dev",
8-
"preview": "rspress preview"
8+
"preview": "rspress preview",
9+
"refresh:showcase-data": "node scripts/refresh-showcase-data.mjs"
910
},
1011
"dependencies": {
1112
"@rspress/core": "2.0.0-beta.34",

multimodal/websites/docs/plugins/showcase-data-plugin.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

multimodal/websites/docs/rspress.config.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { defineConfig } from '@rspress/core';
33
import mermaid from 'rspress-plugin-mermaid';
44

55
import { SEO_CONFIG } from './src/shared/seoConfig';
6-
import { showcaseDataPlugin } from './plugins/showcase-data-plugin';
76

87
const isProd = process.env.NODE_ENV === 'production';
98

@@ -82,7 +81,6 @@ export default defineConfig({
8281
fontSize: 16,
8382
},
8483
}),
85-
showcaseDataPlugin(),
8684
],
8785
themeConfig: {
8886
darkMode: false,
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Rewrites the committed showcase snapshot (`src/data/showcaseShares.ts`) from the
4+
* public shares API. Maintainer-only: it is deliberately kept out of `build` and
5+
* `dev` so the site never needs the API to be up.
6+
*
7+
* Environment:
8+
* SHOWCASE_API_BASE API origin, default is the production worker.
9+
* SHOWCASE_FETCH_VIA Request template containing `{url}`, into which the target
10+
* URL is substituted URL-encoded. Networks that cannot reach
11+
* the worker directly can relay through a CORS/HTTP proxy,
12+
* e.g. 'https://api.allorigins.win/raw?url={url}'.
13+
*/
14+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
15+
import path from 'node:path';
16+
import { fileURLToPath } from 'node:url';
17+
18+
const DEFAULT_API_BASE = 'https://agent-tars.toxichl1994.workers.dev';
19+
const REQUEST_TIMEOUT_MS = 30_000;
20+
21+
// Must match the ApiShareItem field order in src/shared/types.ts.
22+
const KNOWN_FIELDS = [
23+
'sessionId',
24+
'slug',
25+
'url',
26+
'tags',
27+
'title',
28+
'description',
29+
'imageUrl',
30+
'languages',
31+
'author',
32+
'authorGithub',
33+
'authorTwitter',
34+
'date',
35+
];
36+
const REQUIRED_FIELDS = ['sessionId', 'slug', 'url'];
37+
38+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
39+
const targetFile = path.join(scriptDir, '..', 'src', 'data', 'showcaseShares.ts');
40+
41+
function fail(message) {
42+
console.error(`refresh-showcase-data: ${message}`);
43+
process.exit(1);
44+
}
45+
46+
function buildRequestUrl(apiUrl) {
47+
const template = process.env.SHOWCASE_FETCH_VIA;
48+
if (!template) return apiUrl;
49+
if (!template.includes('{url}')) {
50+
fail("SHOWCASE_FETCH_VIA must contain the '{url}' placeholder");
51+
}
52+
return template.replace('{url}', encodeURIComponent(apiUrl));
53+
}
54+
55+
async function fetchShares(apiUrl) {
56+
const requestUrl = buildRequestUrl(apiUrl);
57+
console.log(`fetching ${requestUrl}`);
58+
59+
const response = await fetch(requestUrl, {
60+
headers: { accept: 'application/json' },
61+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
62+
});
63+
if (!response.ok) {
64+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
65+
}
66+
67+
const body = await response.text();
68+
let payload;
69+
try {
70+
payload = JSON.parse(body);
71+
} catch {
72+
throw new Error(`response is not JSON: ${body.slice(0, 200)}`);
73+
}
74+
75+
if (payload.success !== true) {
76+
throw new Error(
77+
`API reported failure: ${payload.error ?? JSON.stringify(payload).slice(0, 200)}`,
78+
);
79+
}
80+
if (!Array.isArray(payload.data) || payload.data.length === 0) {
81+
throw new Error('API returned no records; refusing to overwrite the snapshot');
82+
}
83+
return payload;
84+
}
85+
86+
function validateRecords(records) {
87+
const unknownFields = new Set();
88+
records.forEach((record, index) => {
89+
for (const field of REQUIRED_FIELDS) {
90+
if (typeof record[field] !== 'string' || record[field].length === 0) {
91+
throw new Error(`record #${index} is missing a usable '${field}'`);
92+
}
93+
}
94+
for (const [field, value] of Object.entries(record)) {
95+
if (!KNOWN_FIELDS.includes(field)) {
96+
unknownFields.add(field);
97+
} else if (value !== null && typeof value !== 'string') {
98+
// ApiShareItem models every field as `string | null`; anything else would
99+
// silently break the build instead of failing here.
100+
throw new Error(
101+
`record #${index} field '${field}' is ${typeof value}, expected string or null`,
102+
);
103+
}
104+
}
105+
});
106+
107+
if (unknownFields.size > 0) {
108+
throw new Error(
109+
`API returned unknown fields (${[...unknownFields].join(', ')}); ` +
110+
'add them to ApiShareItem in src/shared/types.ts and to KNOWN_FIELDS here first',
111+
);
112+
}
113+
}
114+
115+
/** Emits records verbatim: no scheme fixing, no reordering, no text rewriting. */
116+
function renderDataFile(records, { apiUrl, fetchedAt }) {
117+
const entries = records
118+
.map((record) => {
119+
const fields = KNOWN_FIELDS.filter((field) => field in record)
120+
.map((field) => ` ${field}: ${JSON.stringify(record[field])},`)
121+
.join('\n');
122+
return ` {\n${fields}\n },`;
123+
})
124+
.join('\n');
125+
126+
return `/**
127+
* Showcase share records captured from the public shares API.
128+
*
129+
* Committed on purpose: the showcase list, detail and replay pages read this
130+
* snapshot, so they keep working when the upstream API is down or unreachable.
131+
* Values are stored exactly as the API returns them — notably \`url\` and
132+
* \`imageUrl\` carry no scheme, which \`ensureHttps\` adds at render time.
133+
*
134+
* Regenerate with \`pnpm refresh:showcase-data\`; it never runs during build or dev.
135+
*
136+
* source: ${apiUrl}
137+
* fetchedAt: ${fetchedAt}
138+
* records: ${records.length}
139+
*/
140+
import type { ApiShareItem } from '../shared/types';
141+
142+
export const showcaseShares: ApiShareItem[] = [
143+
${entries}
144+
];
145+
`;
146+
}
147+
148+
function readPreviousRecordCount() {
149+
try {
150+
const match = readFileSync(targetFile, 'utf8').match(/^ \* records: (\d+)$/m);
151+
return match ? Number(match[1]) : null;
152+
} catch {
153+
return null;
154+
}
155+
}
156+
157+
async function format(source) {
158+
try {
159+
const prettier = await import('prettier');
160+
const config = await prettier.resolveConfig(targetFile);
161+
return await prettier.format(source, { ...config, filepath: targetFile });
162+
} catch (error) {
163+
console.warn(`skipping prettier (${error.message})`);
164+
return source;
165+
}
166+
}
167+
168+
async function main() {
169+
const apiBase = (process.env.SHOWCASE_API_BASE ?? DEFAULT_API_BASE).replace(/\/+$/, '');
170+
const apiUrl = `${apiBase}/shares/public?page=1&limit=100`;
171+
const previousCount = readPreviousRecordCount();
172+
173+
const payload = await fetchShares(apiUrl);
174+
validateRecords(payload.data);
175+
176+
const totalRecords = payload.pagination?.totalRecords;
177+
if (typeof totalRecords === 'number' && totalRecords > payload.data.length) {
178+
throw new Error(
179+
`API reports ${totalRecords} records but only ${payload.data.length} were returned; raise the page limit`,
180+
);
181+
}
182+
183+
const source = await format(
184+
renderDataFile(payload.data, { apiUrl, fetchedAt: new Date().toISOString() }),
185+
);
186+
187+
// Write beside the target and rename, so a crash can never leave a partial file.
188+
const tempFile = `${targetFile}.tmp`;
189+
try {
190+
mkdirSync(path.dirname(targetFile), { recursive: true });
191+
writeFileSync(tempFile, source, 'utf8');
192+
renameSync(tempFile, targetFile);
193+
} catch (error) {
194+
try {
195+
unlinkSync(tempFile);
196+
} catch {
197+
// nothing to clean up
198+
}
199+
throw error;
200+
}
201+
202+
const newCount = payload.data.length;
203+
const change =
204+
previousCount === null
205+
? 'new file'
206+
: `was ${previousCount}, ${newCount >= previousCount ? '+' : ''}${newCount - previousCount}`;
207+
console.log(`wrote ${path.relative(process.cwd(), targetFile)}: ${newCount} records (${change})`);
208+
}
209+
210+
main().catch((error) => fail(error.cause ? `${error.message} (${error.cause})` : error.message));

0 commit comments

Comments
 (0)