diff --git a/.changeset/status-pages.md b/.changeset/status-pages.md new file mode 100644 index 00000000..c2e43d5e --- /dev/null +++ b/.changeset/status-pages.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/astro-renderer": patch +"@cloudhal/cli": patch +--- + +Status pages (RFC-0043 §1, #117). A site that cannot show its pages answers with one small, translated, self-contained page instead of plain text: `not-published` (503, `Retry-After: 3600`, `noindex`) before the first publish, `preview-expired` (410) for a preview link that does not verify, `unavailable` (503) for a Worker that serves no site, and `error` (500, with the `cf-ray` as a reference) when a render fails. The Site Worker answers them before Astro runs, every one `no-store` with its own CSP; `GET /__cloudhal/health` answers `{ "ok": true, "state": "published" | "not-published" }` in the admin's `/api/health` shape. The page is in the request's locale (host or path prefix, then the site default, then English) for every language Studio speaks, right to left where needed, through the same `pnpm i18n:export/import` flow as Studio's catalogs. A theme may restyle it with an optional `statusPage` export in `src/theme.ts` (`@cloudhal/astro-renderer/status-page`), which falls back to the built-in page on a throw, a slow answer or an oversized document and never runs for `error`; `cloudhal dev` draws each state at `/__cloudhal/status/`. The blog starter's routes use `statusPage`/`statusPlain` as their own fallback. **Behaviour change:** an invalid or expired preview link now answers 410 instead of silently showing the published page. diff --git a/AGENTS.md b/AGENTS.md index 9b797768..04ec7fc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ pnpm i18n:export / pnpm i18n:import [--locale xx] # translations as nested ICU - `packages/storage` — `SqlDriver`/`BlobStore` + adapters (`sqlite`, `d1`, `do-sqlite`, `fs`, `r2`; `SqlBlobStore`, `LayeredBlobStore`, `FetcherBlobStore` at the root) + migrations (`migrations/*.ts`, plain SQL). Conformance suites in `src/testing.ts`. - `packages/core-ts` — `SiteGraphStore` (CAS on `rev`, snapshots, diff, publish, rollback). Behavioural suite in `src/testing.ts` runs on sqlite and Miniflare D1. `./local` opens the offline store; `seedStarterSite` seeds a demo. `./surfaces` is RFC-0020's generative UI: the catalog (primitives + block types), eval-free surface checks, bindings, repairs, JSON Patch (`patchPage`), `catalogPrompt`; `./interop` converts A2UI v0.9 and json-render to and from surfaces; `./design` is RFC-0021's DESIGN.md lint and guidance. `./fields` is RFC-0041's typed custom fields (definition and value checks, `resolveFields`, `formatFieldValue`, the agents' JSON Schema, the conversion table); `src/fields-operations/` runs rename/retype/localize/remove. `search.ts` is RFC-0019's FTS5 site search (index written with the publish batch; articles, pages, terms and authors, RFC-0029); `search-provider.ts` is RFC-0029's `SearchProvider` (`fts5`, `memory`, `withFallback`), set with `SiteGraphStoreOptions.search`; `search-ai.ts` is the experimental Cloudflare AI Search provider (`hybridProvider`, `searchProviderFromEnv`, `cloudhal.json` `search`, docs/guides/search.md). - `packages/schedules` — RFC-0008: time model (only `temporal-polyfill/implementation`, `now` injected, never `Temporal.Now`), RRULE subset, release model (`rl1:`), automation/run state, taint and budgets, `runDue`; `./sql` is `SqlScheduleStore`, `./engine` the Temporal-free part core-ts and the site Worker use. Worker-safe; conformance suites in `src/testing.ts` on sqlite and Miniflare D1. -- `packages/astro-renderer` — `GraphSource` (live/snapshot), `cloudhal()` integration (virtual config + dev route-cache watcher), `.astro` section components (`Surface.astro` for RFC-0020 surfaces; `./surfaces` renders them, `./kit` has the table of contents and reading time; `./fields` and `EntryFields.astro` render typed field values, RFC-0041). Routes live in starters, not here. +- `packages/astro-renderer` — `GraphSource` (live/snapshot), `cloudhal()` integration (virtual config + dev route-cache watcher), `.astro` section components (`Surface.astro` for RFC-0020 surfaces; `./surfaces` renders them, `./kit` has the table of contents and reading time; `./fields` and `EntryFields.astro` render typed field values, RFC-0041). `./status-page` is RFC-0043's status pages (`statusPage(state, input) → Response`, served by the Site Worker entry before Astro and by `cloudhal dev` at `/__cloudhal/status/`; the optional `statusPage` theme hook; the catalog in `src/status-strings/` where only `en.ts` is hand-edited and the other languages come from `pnpm i18n:import`). Routes live in starters, not here. - `packages/cli` — the `cloudhal` CLI (`init`, `dev`, `studio`, `mcp`, `deploy`, `push`, `pull`, `export`, `import`, …; every command is in `src/cli/help.ts`, which `cloudhal --help` prints and the docs site's CLI reference is generated from); TS bin run by Node 24 type stripping. `src/cli.ts` parses flags and dispatches through `COMMANDS`, built from `src/cli/commands-{local,remote,content,ops,site}.ts` (a new command is one handler plus one line in `src/cli/help.ts`). `src/archive/` is RFC-0039's `.cloudhal` container (tar, gzip, manifest, limits), read and written by `src/commands/export.ts` and `import*.ts` (`archive-bundle.ts`, `archive-reassemble.ts`); the starter demo archive `test/fixtures/site-archives/starter-blog.cloudhal` is written by `pnpm demo:archive`, and `pnpm check` fails when it is stale. E2E test spawns the bin (Astro disables dev middleware under `VITEST`). Cloudflare tests run both Workers on workerd via wrangler's `createTestHarness`; test files run serially (shared starter caches). - `packages/cloudflare` — the admin Worker (`createAdminWorker`); `./playground` is RFC-0036's playground Worker (`createPlaygroundWorker`, the `PlaygroundSite` Durable Object, signed session cookies). Generated entries must import `@cloudhal/cloudflare/workerd-prelude` first (ADR-0012). - `packages/agent-runtime` / `packages/mcp-server` — policy, skills, audit; MCP tools and transports (ADR-0011). `@cloudhal/mcp-server/render` is RFC-0021's `render_page` core: a small DevTools client, the in-page checks and limits; the CLI drives local Chrome with it (`render-chrome.ts`), the admin Worker Browser Rendering (`browser-render.ts`). diff --git a/packages/astro-renderer/package.json b/packages/astro-renderer/package.json index d06d3bfb..e45a3129 100644 --- a/packages/astro-renderer/package.json +++ b/packages/astro-renderer/package.json @@ -62,7 +62,11 @@ "./locales": "./src/locales.ts", "./locale-middleware": "./src/locale-middleware.ts", "./locale-feeds": "./src/locale-feeds.ts", - "./not-found": "./src/not-found.ts" + "./not-found": "./src/not-found.ts", + "./status-page": "./src/status-page.ts", + "./status-hook": "./src/status-hook.ts", + "./status-serve": "./src/status-serve.ts", + "./status-middleware": "./src/status-middleware.ts" }, "files": [ "dist", @@ -296,6 +300,22 @@ "./not-found": { "types": "./dist/src/not-found.d.ts", "default": "./dist/src/not-found.js" + }, + "./status-page": { + "types": "./dist/src/status-page.d.ts", + "default": "./dist/src/status-page.js" + }, + "./status-hook": { + "types": "./dist/src/status-hook.d.ts", + "default": "./dist/src/status-hook.js" + }, + "./status-serve": { + "types": "./dist/src/status-serve.d.ts", + "default": "./dist/src/status-serve.js" + }, + "./status-middleware": { + "types": "./dist/src/status-middleware.d.ts", + "default": "./dist/src/status-middleware.js" } } } diff --git a/packages/astro-renderer/src/astro-env.d.ts b/packages/astro-renderer/src/astro-env.d.ts index e19348cc..6be51bd9 100644 --- a/packages/astro-renderer/src/astro-env.d.ts +++ b/packages/astro-renderer/src/astro-env.d.ts @@ -19,3 +19,12 @@ declare module "virtual:cloudhal/comments-client" { const client: import("./comments-client.ts").CommentsClient | null; export default client; } + +declare module "virtual:cloudhal/theme-hooks" { + /** + * RFC-0043 §1.7: loads the project's `src/theme.ts` lazily (an empty module when the project + * has none), so a status page can ask for the theme's `statusPage` hook without evaluating + * the theme on the normal path. + */ + export const loadTheme: () => Promise>; +} diff --git a/packages/astro-renderer/src/cloudflare-entry.ts b/packages/astro-renderer/src/cloudflare-entry.ts index 5af6d96e..ef42ec32 100644 --- a/packages/astro-renderer/src/cloudflare-entry.ts +++ b/packages/astro-renderer/src/cloudflare-entry.ts @@ -32,17 +32,26 @@ * render the section themselves (`loadComments`, ``) and set their own CSP when an * open form uses Turnstile; `withSecurityHeaders` keeps it. All of it is off without a usable * `CLOUDHAL_COMMENTS_KEY` (or when the build has no comments module). + * - Status pages (RFC-0043 §1): before Astro, the entry decides the site's state — `not-published` + * before the first publish, `preview-expired` for a preview token that does not verify, + * `unavailable` for a Worker that serves no site — and answers page paths with the renderer's + * own page (`status-page.ts`: 503/410, `Retry-After`, `no-store`, its own CSP), restyled by the + * theme's optional `statusPage` hook. A failure anywhere becomes the `error` page (500) with the + * `cf-ray` as its reference. `GET /__cloudhal/health` answers `{ ok, state }` for monitors. */ import { cache } from "cloudflare:workers"; import config from "virtual:cloudhal/config"; +import { loadTheme } from "virtual:cloudhal/theme-hooks"; import { handle } from "@astrojs/cloudflare/handler"; import { type WaeDataset, WaeWriter } from "@cloudhal/analytics/wae"; import { analyticsBackend, CACHE_TAG_PREFIX, resetSource, + type SiteStateResult, serveMedia, + siteState, withRequestSite, } from "@cloudhal/astro-renderer/runtime"; import { SqlCommentStore } from "@cloudhal/comments/sql"; @@ -63,6 +72,9 @@ import { hostRedirect, type RedirectEnv } from "./host-redirect.ts"; import { PREVIEW_PARAM } from "./resolver.ts"; import { SearchLimiter, searchCost } from "./search-limit.ts"; import { SiteDispatch, unknownHost, withNoindex } from "./site-dispatch.ts"; +import { renderStatus, themeStatusHook } from "./status-hook.ts"; +import { type SiteState, statusPage } from "./status-page.ts"; +import { isStatusPagePath, statusInput } from "./status-serve.ts"; interface Env extends RedirectEnv { CLOUDHAL_SITE_ID?: string; @@ -142,6 +154,67 @@ const pendingWrites = new PendingWrites(); const analyticsBySite = new Map(); const commentsBySite = new Map(); let dispatch: SiteDispatch | undefined; +/** RFC-0043 §1.7: the theme's `statusPage` hook, loaded the first time a status page is drawn. */ +const statusHook = themeStatusHook(loadTheme, (m, e) => console.error(m, e)); + +/** RFC-0043 §1.3: the `error` page's reference — Cloudflare's ray id, else a random one. */ +function referenceId(request: Request): string { + return ( + request.headers.get("cf-ray") ?? + Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => + b.toString(16).padStart(2, "0"), + ).join("") + ); +} + +/** The `error` page (RFC-0043 §1.2): never the theme's hook, nothing read from the store. */ +function errorPage(request: Request, error: unknown): Response { + const rayId = referenceId(request); + console.error(`cloudhal: request failed (reference ${rayId})`, error); + return statusPage("error", { locale: "en", now: Date.now(), rayId }, { method: request.method }); +} + +/** + * A 5xx HTML answer from Astro becomes the `error` page unless the theme marked it as its own + * (`x-cloudhal-error-page: theme`, a theme's `500.astro`). + */ +function withErrorPage(response: Response, request: Request): Response { + if ( + response.status < 500 || + !(response.headers.get("content-type") ?? "").includes("text/html") || + response.headers.get("x-cloudhal-error-page") === "theme" + ) + return response; + return errorPage(request, new Error(`Astro answered ${response.status}`)); +} + +/** + * RFC-0043 §1.9: `{ ok, state }`, the admin's `/api/health` shape plus the site's state, so one + * monitor rule reads both. Public, never cached, never indexed; reads only the memoised pointer. + */ +async function health(request: Request): Promise { + const headers = { + "cache-control": "no-store", + "cloudflare-cdn-cache-control": "no-store", + "x-robots-tag": "noindex", + }; + if (request.method !== "GET" && request.method !== "HEAD") + return Response.json( + { error: "method_not_allowed" }, + { status: 405, headers: { ...headers, allow: "GET" } }, + ); + try { + const state = await siteState(request); + if (state.kind === "unavailable") return Response.json({ ok: false }, { status: 503, headers }); + return Response.json( + { ok: true, state: state.kind === "not-published" ? "not-published" : "published" }, + { headers }, + ); + } catch (error) { + console.error("cloudhal: health check could not read the store", error); + return Response.json({ ok: false }, { status: 503, headers }); + } +} /** One per isolate: which site a request is for (RFC-0034 §4). */ function siteDispatch(env: Env, siteIdFromBuild: string | undefined): SiteDispatch { @@ -504,8 +577,11 @@ async function serveSite( const media = siteId ? await serveMedia(request, { siteId }) : undefined; const rendered = media ?? - withSecurityHeaders( - cacheOnlySuccess((await handle(request, env as never, ctx)) as unknown as Response), + withErrorPage( + withSecurityHeaders( + cacheOnlySuccess((await handle(request, env as never, ctx)) as unknown as Response), + ), + request, ); // Server-side render events (§5.4), off the response path: published HTML 200s (cache // misses), redirects, 404, 410, 451 and 5xx; never previews or HEAD. @@ -529,6 +605,44 @@ async function serveSite( : stripped; } +/** + * RFC-0043 §2.5: the status page for a request the site cannot serve, drawn with the site data + * the state allows (§1.6) and the theme's hook; counted as a render event like any 5xx (§1.8). + */ +async function statusResponse( + call: { request: Request; env: Env; ctx: ExecutionContext }, + state: Exclude, + siteId: string, +): Promise { + const { request, env, ctx } = call; + const url = new URL(request.url); + const kind: SiteState = state.kind; + const sites = + state.kind === "unavailable" + ? {} + : { + live: state.site, + published: state.kind === "preview-expired" ? state.published : undefined, + }; + const started = Date.now(); + const response = await renderStatus( + kind, + statusInput(kind, sites, { host: url.host, pathname: url.pathname }, started), + { + hook: await statusHook(), + method: request.method, + csp: SITE_CSP, + onFailure: (m, e) => console.error(m, e), + }, + ); + const a = state.kind !== "unavailable" ? await siteAnalytics(env, siteId) : undefined; + if (a?.available) + ctx.waitUntil( + a.render(request, response, Date.now() - started).then(() => pendingWrites.settled()), + ); + return response; +} + export function createSiteWorker(options: { siteId?: string } = {}) { const searchLimiter = new SearchLimiter(); async function route(request: Request, env: Env, ctx: ExecutionContext): Promise { @@ -541,15 +655,30 @@ export function createSiteWorker(options: { siteId?: string } = {}) { // runs (Astro's render included) then sees that site. const choice = await siteDispatch(env, options.siteId).choose(request); if (choice.kind === "unknown") return unknownHost(); - const siteId = choice.siteId; - const run = () => serveSite({ request, env, ctx }, siteId, searchLimiter); - const response = siteId ? await withRequestSite(siteId, run) : await run(); + // Without `CLOUDHAL_SITE_ID` a single-site Worker serves the site it was built for, as + // Astro's `getSource` always did; `unavailable` is for a site row that is gone (§2.5). + const siteId = choice.siteId ?? config.siteId; + const call = { request, env, ctx }; + const run = async () => { + if (url.pathname === "/__cloudhal/health") return health(request); + const page = + (request.method === "GET" || request.method === "HEAD") && isStatusPagePath(url.pathname); + const state = page ? await siteState(request) : undefined; + if (state && state.kind !== "serve") return statusResponse(call, state, siteId); + return serveSite(call, siteId, searchLimiter); + }; + const response = await withRequestSite(siteId, run); return choice.noindex ? withNoindex(response) : response; } return { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { // #62: the edge cache is shared by every host this Worker answers on, unless told to vary. - return varyByHost(await route(request, env, ctx)); + try { + return varyByHost(await route(request, env, ctx)); + } catch (error) { + // RFC-0043 §1.8: whatever failed, the visitor gets a page and a reference to quote. + return varyByHost(errorPage(request, error)); + } }, }; } diff --git a/packages/astro-renderer/src/integration.ts b/packages/astro-renderer/src/integration.ts index 91aed255..67f60267 100644 --- a/packages/astro-renderer/src/integration.ts +++ b/packages/astro-renderer/src/integration.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; import type { AstroIntegration } from "astro"; import type { AnalyticsClient } from "./analytics-client.ts"; import type { CommentsClient } from "./comments-client.ts"; @@ -61,6 +63,14 @@ const RESOLVED_CLIENT_ID = `\0${CLIENT_ID}`; /** Dev only: the built comments module and worker, served from memory by the comments middleware. */ const COMMENTS_CLIENT_ID = "virtual:cloudhal/comments-client"; const RESOLVED_COMMENTS_CLIENT_ID = `\0${COMMENTS_CLIENT_ID}`; +/** + * RFC-0043 §1.7: a lazy loader of the project's `src/theme.ts` for the status pages' theme hook + * (root-relative, so Vite resolves it in dev and in the Site Worker build), or of an empty module + * when the project has no theme module. Evaluated only when a status page is drawn. + */ +const THEME_HOOKS_ID = "virtual:cloudhal/theme-hooks"; +const RESOLVED_THEME_HOOKS_ID = `\0${THEME_HOOKS_ID}`; +const THEME_MODULE = "src/theme.ts"; /** The virtual modules: the project config, and in dev the built analytics and comments clients. */ function configPlugin(modules: { config: unknown; client: unknown; comments: unknown }) { @@ -68,27 +78,46 @@ function configPlugin(modules: { config: unknown; client: unknown; comments: unk [VIRTUAL_ID]: RESOLVED_ID, [CLIENT_ID]: RESOLVED_CLIENT_ID, [COMMENTS_CLIENT_ID]: RESOLVED_COMMENTS_CLIENT_ID, + [THEME_HOOKS_ID]: RESOLVED_THEME_HOOKS_ID, }; const bodies: Record = { [RESOLVED_ID]: modules.config, [RESOLVED_CLIENT_ID]: modules.client, [RESOLVED_COMMENTS_CLIENT_ID]: modules.comments, }; + let root = process.cwd(); return { name: "cloudhal-config", + configResolved: (config: { root: string }) => { + root = config.root; + }, resolveId: (id: string) => (Object.hasOwn(ids, id) ? ids[id] : undefined), - load: (id: string) => - Object.hasOwn(bodies, id) ? `export default ${JSON.stringify(bodies[id])};` : undefined, + load: (id: string) => { + if (id === RESOLVED_THEME_HOOKS_ID) + return existsSync(path.join(root, THEME_MODULE)) + ? `export const loadTheme = () => import("/${THEME_MODULE}");` + : "export const loadTheme = () => Promise.resolve({});"; + return Object.hasOwn(bodies, id) + ? `export default ${JSON.stringify(bodies[id])};` + : undefined; + }, }; } /** * What only `cloudhal dev` runs as middleware (the Site Worker's entry and builds do these jobs): * the analytics beacon (RFC-0006 §5.1); the click-to-load video player (RFC-0015 §4) and the - * interaction runtime (RFC-0020 §4), which builds write as files; and the Site Worker's CSP sent - * report-only, so the console shows what a deploy would block. + * interaction runtime (RFC-0020 §4), which builds write as files; the Site Worker's CSP sent + * report-only, so the console shows what a deploy would block; and the status pages' preview + * route (RFC-0043 §1.8). */ -const DEV_MIDDLEWARE = ["analytics-middleware", "embed-middleware", "csp-middleware"].map((m) => ({ +const DEV_MIDDLEWARE = [ + "analytics-middleware", + "embed-middleware", + "csp-middleware", + // RFC-0043 §1.8: the status pages drawn on demand at `/__cloudhal/status/`. + "status-middleware", +].map((m) => ({ entrypoint: `@cloudhal/astro-renderer/${m}`, order: "pre" as const, })); diff --git a/packages/astro-renderer/src/runtime-workerd.ts b/packages/astro-renderer/src/runtime-workerd.ts index 3aabb5e3..7d9608a1 100644 --- a/packages/astro-renderer/src/runtime-workerd.ts +++ b/packages/astro-renderer/src/runtime-workerd.ts @@ -23,7 +23,7 @@ import { usableCommentsKey } from "./comments.ts"; import type { CloudhalRendererConfig } from "./integration.ts"; import type { PeerEdition } from "./locales.ts"; import { matchMediaRoute, serveMediaRequest } from "./media-route.ts"; -import { PREVIEW_PARAM, SiteResolver } from "./resolver.ts"; +import { PREVIEW_PARAM, type Resolution, SiteResolver } from "./resolver.ts"; import type { GraphSource } from "./source.ts"; /** RFC-0029 §3: `AI_SEARCH`, `SEARCH_BUCKET` and `CLOUDHAL_SEARCH_*` turn on semantic search. */ @@ -143,6 +143,66 @@ export function resetSource(siteId?: string): void { for (const [id, r] of resolvers) if (!siteId || id === siteId) r.invalidate(); } +/** + * RFC-0043 §1.8: one resolution per request. The entry decides the site's state before Astro + * runs and Astro's `getSource` resolves the same request, so the verdict (pointer read, preview + * token) is memoised on the request object; a request Astro re-created simply resolves again. + */ +const resolutions = new WeakMap>(); + +function resolveRequest(r: SiteResolver, request: Request): Promise { + let pending = resolutions.get(request); + if (!pending) { + pending = r.resolve(request); + resolutions.set(request, pending); + } + return pending; +} + +/** RFC-0043 §2.5: what the entry answers for this request, decided before Astro runs. */ +export type SiteStateResult = + | { readonly kind: "serve" } + /** The site row is missing (deleted, RFC-0034). */ + | { readonly kind: "unavailable" } + /** No published snapshot and no valid preview: `site` is the live node (its name, locales). */ + | { readonly kind: "not-published"; readonly site: Site } + /** A preview token in the URL that does not verify; `published` is the published snapshot's site node, if any. */ + | { + readonly kind: "preview-expired"; + readonly site: Site; + readonly published?: Site | undefined; + }; + +/** + * The state of the request's site (RFC-0043 §2.5): `serve` for a published site or a valid + * preview, `not-published` before the first publish, `preview-expired` for a token in the URL + * that does not verify (§1.2: it no longer falls back to the published page). Shares the + * request's resolution with `getSource`. + */ +export async function siteState(request: Request, siteId?: string): Promise { + const id = siteOf(siteId); + if (!id) return { kind: "unavailable" }; + const r = getResolver(id); + let resolution: Resolution; + try { + resolution = await resolveRequest(r, request); + } catch (error) { + if (error instanceof Error && /^site .* not found$/.test(error.message)) + return { kind: "unavailable" }; + throw error; + } + if (resolution.kind === "preview") return { kind: "serve" }; + const fromUrl = new URL(request.url).searchParams.get(PREVIEW_PARAM); + if (resolution.previewError && fromUrl && fromUrl !== "exit") + return { + kind: "preview-expired", + site: await r.site(), + published: resolution.kind === "published" ? await resolution.source.site() : undefined, + }; + if (resolution.kind === "unpublished") return { kind: "not-published", site: await r.site() }; + return { kind: "serve" }; +} + /** * What site analytics read on the Site Worker (`SiteAnalytics`, RFC-0006 §5.2): D1 for settings and * the daily salt, the site row (published pointer, domains) and the isolate's snapshot cache. @@ -237,7 +297,7 @@ export async function getSource( if (!astro) throw new Error("on Cloudflare, pass the Astro global: getSource(config, Astro)"); const siteId = siteOf(config.siteId) as string; const r = getResolver(siteId); - const resolution = await r.resolve(astro.request); + const resolution = await resolveRequest(r, astro.request); const h = astro.response.headers; if (resolution.kind === "preview") { h.set("cache-control", "private, no-store"); @@ -296,7 +356,7 @@ export async function serveMedia( const siteId = siteOf(options.siteId); if (!siteId) return undefined; const r = getResolver(siteId); - const resolution = await r.resolve(request); + const resolution = await resolveRequest(r, request); if (resolution.kind === "unpublished") return new Response("not found", { status: 404, diff --git a/packages/astro-renderer/src/runtime.ts b/packages/astro-renderer/src/runtime.ts index 24b22e18..ae913ac4 100644 --- a/packages/astro-renderer/src/runtime.ts +++ b/packages/astro-renderer/src/runtime.ts @@ -158,5 +158,24 @@ export async function commentsBackend( return { store: new SqlCommentStore((await localStore(config.dataDir)).sql) }; } +/** RFC-0043 §2.5: the `workerd` build's verdict shape, for code importing either build. */ +export type SiteStateResult = + | { readonly kind: "serve" } + | { readonly kind: "unavailable" } + | { readonly kind: "not-published"; readonly site: Site } + | { + readonly kind: "preview-expired"; + readonly site: Site; + readonly published?: Site | undefined; + }; + +/** + * RFC-0043 §1.8: `cloudhal dev` renders the working copy, drafts included, so a request on Node + * is always served; the status pages are drawn on demand at `/__cloudhal/status/`. + */ +export async function siteState(_request: Request, _siteId?: string): Promise { + return { kind: "serve" }; +} + /** Shared with the `workerd` runtime so code importing either build sees the same names. */ export { CACHE_TAG_PREFIX } from "./cache-tags.ts"; diff --git a/packages/astro-renderer/src/status-hook.ts b/packages/astro-renderer/src/status-hook.ts new file mode 100644 index 00000000..892a3349 --- /dev/null +++ b/packages/astro-renderer/src/status-hook.ts @@ -0,0 +1,141 @@ +/** + * The theme hook for status pages (RFC-0043 §1.7): a theme may export `statusPage(state, ctx)` + * from `src/theme.ts` and return its own document. The renderer keeps the status code, the + * headers and the CSP (invariant 9); the built-in page is used when the hook returns + * `undefined`, throws, answers with something other than a string, exceeds 64 KB, or takes more + * than 50 ms — logged once per state and isolate — and always for `error`, which must not depend + * on code that may be the cause. Worker-safe. + */ +import { SITE_CSP } from "./csp.ts"; +import { messageHtml, type StatusDir } from "./status-html.ts"; +import { + HOOK_HTML_LIMIT, + type SiteState, + type StatusPageInput, + type StatusStrings, + statusCode, + statusPage, + statusPageHtml, + statusStrings, +} from "./status-page.ts"; + +/** The states a theme may draw. */ +export type HookState = Exclude; + +export interface StatusContext extends StatusPageInput { + readonly status: 503 | 410; + readonly dir: StatusDir; + /** The built-in words, already in the page's locale. */ + readonly strings: StatusStrings; + /** The owner's message, escaped, line breaks as `
` (never use `message.text` raw). */ + readonly messageHtml?: string | undefined; + /** "Back by …", formatted. */ + readonly untilText?: string | undefined; + /** The built-in document, with extra CSS appended. */ + defaultHtml(options?: { css?: string | undefined }): string; +} + +export type StatusPageHook = ( + state: HookState, + ctx: StatusContext, +) => string | undefined | Promise; + +/** How long a hook may take before the built-in page answers instead. */ +export const HOOK_TIMEOUT_MS = 50; + +const failed = new Set(); + +/** Loads `src/theme.ts` lazily (the integration's `virtual:cloudhal/theme-hooks`) and memoises the hook. */ +export function themeStatusHook( + loadTheme: () => Promise, + onError: (message: string, error: unknown) => void = () => {}, +): () => Promise { + let pending: Promise | undefined; + return () => { + pending ??= loadTheme().then( + (mod) => { + const hook = (mod as { statusPage?: unknown } | null)?.statusPage; + return typeof hook === "function" ? (hook as StatusPageHook) : undefined; + }, + (error: unknown) => { + onError("cloudhal: the theme module could not be loaded for the status page", error); + return undefined; + }, + ); + return pending; + }; +} + +export interface RenderStatusOptions { + readonly hook?: StatusPageHook | undefined; + readonly method?: string | undefined; + readonly onFailure?: ((message: string, error?: unknown) => void) | undefined; + /** The site's policy for a theme's document (default `SITE_CSP`). */ + readonly csp?: string | undefined; + /** Test hook: the hook's time budget. */ + readonly timeoutMs?: number | undefined; +} + +/** Runs the hook within its budget; `undefined` means the built-in page. */ +async function hookHtml( + state: HookState, + input: StatusPageInput, + hook: StatusPageHook, + options: RenderStatusOptions, +): Promise { + const strings = statusStrings(state, input); + const ctx: StatusContext = { + ...input, + status: statusCode(state) as 503 | 410, + dir: strings.dir, + strings, + ...(input.message && state === "maintenance" + ? { messageHtml: messageHtml(input.message.text) } + : {}), + untilText: strings.untilText, + defaultHtml: (o = {}) => statusPageHtml(state, input, o), + }; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`took more than ${options.timeoutMs ?? HOOK_TIMEOUT_MS} ms`)), + options.timeoutMs ?? HOOK_TIMEOUT_MS, + ); + }); + try { + const result = await Promise.race([Promise.resolve().then(() => hook(state, ctx)), timeout]); + if (result === undefined) return undefined; + if (typeof result !== "string") throw new Error("returned something other than a string"); + if (result.length > HOOK_HTML_LIMIT) + throw new Error(`returned more than ${HOOK_HTML_LIMIT} bytes`); + return result; + } catch (error) { + if (!failed.has(state)) { + failed.add(state); + options.onFailure?.(`cloudhal: the theme's statusPage hook failed for ${state}`, error); + } + return undefined; + } finally { + clearTimeout(timer); + } +} + +/** Test hook: forget which states' hook failures were logged. */ +export function resetHookFailures(): void { + failed.clear(); +} + +/** The status response for `state`: the theme's document when the hook gives one, else the built-in page. */ +export async function renderStatus( + state: SiteState, + input: StatusPageInput, + options: RenderStatusOptions = {}, +): Promise { + if (state === "error" || !options.hook) + return statusPage(state, input, { method: options.method }); + const html = await hookHtml(state, input, options.hook, options); + return statusPage(state, input, { + method: options.method, + ...(html !== undefined ? { html, csp: options.csp ?? SITE_CSP } : {}), + }); +} diff --git a/packages/astro-renderer/src/status-html.ts b/packages/astro-renderer/src/status-html.ts new file mode 100644 index 00000000..fc4a9479 --- /dev/null +++ b/packages/astro-renderer/src/status-html.ts @@ -0,0 +1,157 @@ +/** + * The built-in status document (RFC-0043 §1.3): one self-contained HTML page under 8 KB with + * inline CSS, the system font stack, no scripts and no requests except the site's own logo. + * Light and dark through `prefers-color-scheme`, logical properties only (ADR-0033), and the + * published site's tokens (§1.3) as four custom properties when the state allows them. + */ +import type { StatusMessages } from "./status-strings/en.ts"; + +export type StatusDir = "ltr" | "rtl"; + +/** The published site's look, read from `Site.theme.config` (§1.3); every field optional. */ +export interface StatusTheme { + /** `#rrggbb`: a decorative accent (never text), so it needs no contrast check. */ + readonly brand?: string | undefined; + /** `#rrggbb`: the page background; used only together with `text` and when they contrast. */ + readonly surface?: string | undefined; + /** `#rrggbb`: the text color; used only together with `surface` and when they contrast. */ + readonly text?: string | undefined; + readonly font?: "sans" | "serif" | "mono" | undefined; +} + +/** What the template draws; every piece already chosen and, where it is text, still unescaped. */ +export interface StatusDocument { + readonly state: string; + readonly locale: string; + readonly dir: StatusDir; + readonly noindex: boolean; + readonly words: Pick & { title: string; body: string }; + readonly siteName?: string | undefined; + /** A same-origin `/media/…` URL only (the page's CSP allows `img-src 'self'`). */ + readonly logo?: string | undefined; + readonly message?: { readonly text: string; readonly locale: string; readonly dir: StatusDir }; + /** "Back by …", already formatted. */ + readonly until?: string | undefined; + /** "Reference: …", already formatted. */ + readonly reference?: string | undefined; + readonly theme?: StatusTheme | undefined; + /** Extra CSS from a theme hook's `defaultHtml({ css })`, appended after the built-in rules. */ + readonly css?: string | undefined; +} + +export function escapeHtml(s: string): string { + return s.replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string, + ); +} + +/** The owner's message: escaped, line breaks as `
`; never markup or links (§2.10). */ +export function messageHtml(text: string): string { + return escapeHtml(text.replace(/\r\n?/g, "\n")) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .join("
"); +} + +const HEX = /^#[0-9a-f]{6}$/i; + +function luminance(hex: string): number { + const channel = (i: number) => { + const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} + +/** WCAG 2.x contrast ratio of two `#rrggbb` colors. */ +export function contrastRatio(a: string, b: string): number { + const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; + return (l1 + 0.05) / (l2 + 0.05); +} + +/** Only typed values reach the stylesheet: hex colors (a readable pair) and a font keyword. */ +function themeCss(theme: StatusTheme | undefined): string { + if (!theme) return ""; + const decl: string[] = []; + if (theme.brand && HEX.test(theme.brand)) decl.push(`--ch-status-brand:${theme.brand}`); + if ( + theme.surface && + theme.text && + HEX.test(theme.surface) && + HEX.test(theme.text) && + contrastRatio(theme.surface, theme.text) >= 4.5 + ) + decl.push(`--ch-status-surface:${theme.surface}`, `--ch-status-text:${theme.text}`); + const stacks = { + sans: "system-ui, sans-serif", + serif: "ui-serif, Georgia, serif", + mono: "ui-monospace, Menlo, monospace", + }; + if (theme.font && theme.font in stacks) decl.push(`--ch-status-font:${stacks[theme.font]}`); + return decl.length ? `:root{${decl.join(";")}}` : ""; +} + +/** The built-in rules: about 1.2 KB, logical properties only. */ +const CSS = [ + ":root{--ch-status-brand:#2563eb;--ch-status-surface:#f6f7f9;--ch-status-text:#1a1d21;", + "--ch-status-muted:#5b6470;--ch-status-card:#ffffff;--ch-status-font:system-ui,sans-serif;", + "color-scheme:light dark}", + "@media(prefers-color-scheme:dark){:root{--ch-status-surface:#111418;--ch-status-text:#e8eaed;", + "--ch-status-muted:#a3abb5;--ch-status-card:#1b1f25}}", + "*{box-sizing:border-box}", + "html{background:var(--ch-status-surface);color:var(--ch-status-text);", + "font:16px/1.5 var(--ch-status-font)}", + "body{margin:0;min-height:100vh;display:grid;grid-template-rows:1fr auto;", + "padding-block:1rem;padding-inline:1rem}", + ".ch-status{align-self:center;justify-self:center;inline-size:100%;max-inline-size:32rem;", + "background:var(--ch-status-card);border-block-start:4px solid var(--ch-status-brand);", + "border-radius:.75rem;padding-block:2rem;padding-inline:1.75rem;", + "box-shadow:0 1px 2px rgba(0,0,0,.06),0 8px 24px rgba(0,0,0,.08)}", + ".ch-status__logo{max-block-size:3rem;max-inline-size:12rem;margin-block-end:1rem}", + ".ch-status__site{margin:0 0 .5rem;font-size:.875rem;letter-spacing:.04em;", + "text-transform:uppercase;color:var(--ch-status-muted)}", + "h1{margin:0 0 .75rem;font-size:1.625rem;line-height:1.25}", + "p{margin:0 0 .75rem}", + ".ch-status__message{padding-block:.75rem;padding-inline:1rem;", + "border-inline-start:3px solid var(--ch-status-brand);", + "background:color-mix(in srgb,var(--ch-status-brand) 8%,transparent);border-radius:.25rem}", + ".ch-status__until,.ch-status__ref{color:var(--ch-status-muted);font-size:.9375rem}", + "code{font-family:ui-monospace,Menlo,monospace;font-size:.9em}", + "footer{justify-self:center;margin-block-start:1.5rem;font-size:.8125rem;", + "color:var(--ch-status-muted)}", +].join(""); + +/** The document for `doc`, as a string. */ +export function statusDocument(doc: StatusDocument): string { + const e = escapeHtml; + const title = doc.siteName ? `${doc.words.title} · ${doc.siteName}` : doc.words.title; + const parts = [ + "", + ``, + "", + '', + '', + doc.noindex ? '' : "", + `${e(title)}`, + ``, + "", + "", + `
`, + doc.logo ? `` : "", + doc.siteName ? `

${e(doc.siteName)}

` : "", + `

${e(doc.words.title)}

`, + `

${e(doc.words.body)}

`, + doc.message + ? `

${messageHtml(doc.message.text)}

` + : "", + doc.until ? `

${e(doc.until)}

` : "", + doc.reference ? `

${e(doc.reference)}

` : "", + "
", + `
${e(doc.words.builtWith)}
`, + "", + "", + ]; + return `${parts.filter(Boolean).join("\n")}\n`; +} diff --git a/packages/astro-renderer/src/status-middleware.ts b/packages/astro-renderer/src/status-middleware.ts new file mode 100644 index 00000000..8ff10b4f --- /dev/null +++ b/packages/astro-renderer/src/status-middleware.ts @@ -0,0 +1,61 @@ +/** + * Dev middleware (added by the `cloudhal()` integration for Node targets, RFC-0043 §1.8): + * `cloudhal dev` renders the working copy, drafts included, so it never answers a real page with + * a status page. For parity it draws each page on demand, through the same module and the + * project's theme hook, at `/__cloudhal/status/` (`?locale=tr`, `?page=coming-soon`, + * `?message=…`, `?until=`), so a theme author can see and screenshot their override. + */ +import config from "virtual:cloudhal/config"; +import { loadTheme } from "virtual:cloudhal/theme-hooks"; +import { getSource } from "@cloudhal/astro-renderer/runtime"; +import { renderStatus, themeStatusHook } from "./status-hook.ts"; +import { isSiteState, type StatusPageInput } from "./status-page.ts"; +import { statusInput } from "./status-serve.ts"; + +interface MiddlewareContext { + request: Request; +} + +export const STATUS_PREVIEW_PATH = /^\/__cloudhal\/status\/([a-z-]+)\/?$/; + +const hook = themeStatusHook(loadTheme, (m, e) => console.error(m, e)); + +export async function onRequest( + context: MiddlewareContext, + next: () => Promise, +): Promise { + const url = new URL(context.request.url); + const m = STATUS_PREVIEW_PATH.exec(url.pathname); + if (!m) return next(); + const state = m[1] as string; + if (!isSiteState(state)) + return new Response(`unknown state "${state}"`, { + status: 404, + headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }, + }); + const site = await (await getSource(config)).site(); + const now = Date.now(); + // Dev shows the working copy: the live node stands in for the published one here. + const base = statusInput( + state, + { live: site, published: site }, + { host: url.host, pathname: "/" }, + now, + ); + const locale = url.searchParams.get("locale"); + const message = url.searchParams.get("message"); + const until = url.searchParams.get("until"); + const input: StatusPageInput = { + ...base, + ...(locale ? { locale } : {}), + page: url.searchParams.get("page") === "coming-soon" ? "coming-soon" : "maintenance", + ...(message ? { message: { text: message, locale: locale ?? site.defaultLocale } } : {}), + ...(until ? { until: { at: until, timeZone: site.timeZone } } : {}), + ...(state === "error" ? { rayId: "dev-preview" } : {}), + }; + return renderStatus(state, input, { + hook: await hook(), + method: context.request.method, + onFailure: (msg, e) => console.error(msg, e), + }); +} diff --git a/packages/astro-renderer/src/status-page.ts b/packages/astro-renderer/src/status-page.ts new file mode 100644 index 00000000..81a3c241 --- /dev/null +++ b/packages/astro-renderer/src/status-page.ts @@ -0,0 +1,323 @@ +/** + * Status pages (RFC-0043 §1): the one page a site answers with when it cannot show its pages — + * not published yet, in maintenance, unavailable, failed, or behind an expired preview link — + * with the right status code, `Retry-After`, `no-store` and its own CSP. Pure and Worker-safe: + * it imports only the catalog and the template, takes an injected clock, and never throws (a + * broken input degrades to the English page). Where it is served: `cloudflare-entry.ts` before + * Astro, `status-middleware.ts` in `cloudhal dev`, and the starter's routes as their fallback. + */ +import { + messageHtml, + type StatusDir, + type StatusDocument, + type StatusTheme, + statusDocument, +} from "./status-html.ts"; +import { STATUS_CATALOG } from "./status-strings/catalog.ts"; +import type { StatusMessages } from "./status-strings/en.ts"; + +// The hook's types, so a theme imports everything from one subpath (§1.7). +export type { HookState, StatusContext, StatusPageHook } from "./status-hook.ts"; +export type { StatusTheme } from "./status-html.ts"; + +export type SiteState = + | "not-published" + | "maintenance" + | "unavailable" + | "error" + | "preview-expired"; + +export const SITE_STATES: readonly SiteState[] = [ + "not-published", + "maintenance", + "unavailable", + "error", + "preview-expired", +]; + +export const isSiteState = (value: string): value is SiteState => + (SITE_STATES as readonly string[]).includes(value); + +export interface StatusPageInput { + /** §1.6 decides what is passed per state; `logo` is a same-origin `/media/…` URL. */ + readonly site?: { readonly name: string; readonly logo?: string | undefined } | undefined; + /** Already chosen (§1.4: host or path prefix, the site default, then English). */ + readonly locale: string; + /** Maintenance wording (§2.1 `page`); `maintenance` when absent. */ + readonly page?: "maintenance" | "coming-soon" | undefined; + /** Maintenance only: the owner's plain-text message and its language. */ + readonly message?: { readonly text: string; readonly locale: string } | undefined; + /** Maintenance only: when it ends, for "Back by …" and `Retry-After`. */ + readonly until?: { readonly at: string; readonly timeZone?: string | undefined } | undefined; + /** The published site's tokens (§1.3); the caller passes them only where the state allows. */ + readonly theme?: StatusTheme | undefined; + /** `error` only: the request id a visitor can quote (`cf-ray`). */ + readonly rayId?: string | undefined; + /** Injected clock (RFC-0008 §9.4). */ + readonly now: number; +} + +/** The built-in page's own policy (§1.3): inline style, same-origin images, nothing else. */ +export const STATUS_CSP = + "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; + +/** The built-in document never exceeds this (§1.3); a test draws every state in every language. */ +export const STATUS_PAGE_LIMIT = 8192; + +/** A theme hook's document may be this large before the built-in page is used instead (§1.7). */ +export const HOOK_HTML_LIMIT = 65536; + +const STATUS: Record = { + "not-published": 503, + maintenance: 503, + unavailable: 503, + error: 500, + "preview-expired": 410, +}; + +const NOINDEX: ReadonlySet = new Set(["not-published", "preview-expired"]); + +/** RTL scripts by primary language subtag (CLDR), for locales the catalog does not list. */ +const RTL = new Set(["ar", "fa", "he", "ur", "ps", "ckb", "dv", "yi", "ug", "sd", "ks", "ku"]); + +export function statusCode(state: SiteState): 503 | 500 | 410 { + return STATUS[state]; +} + +/** Delay-seconds (§1.2), or nothing for `error` and `preview-expired`. */ +export function retryAfter( + state: SiteState, + input: Pick, +): string | undefined { + if (state === "not-published") return "3600"; + if (state === "unavailable") return "300"; + if (state !== "maintenance") return undefined; + const end = input.until ? Date.parse(input.until.at) : Number.NaN; + if (!Number.isFinite(end)) return "3600"; + return String(Math.max(60, Math.ceil((end - input.now) / 1000))); +} + +/** + * The catalog locale for a site locale (§1.4): exact, then the primary language subtag + * (`pt-PT` → `pt-BR`, `zh-HK` → `zh-Hans`), then English. + */ +export function catalogLocale(locale: string): string { + if (Object.hasOwn(STATUS_CATALOG, locale)) return locale; + const language = locale.toLowerCase().split("-")[0] ?? ""; + if (Object.hasOwn(STATUS_CATALOG, language)) return language; + const same = Object.keys(STATUS_CATALOG).find((l) => l.split("-")[0]?.toLowerCase() === language); + return same ?? "en"; +} + +/** `` for a locale: the catalog's entry, else by script. */ +export function statusDirection(locale: string): StatusDir { + const entry = STATUS_CATALOG[catalogLocale(locale)]; + if (entry && catalogLocale(locale) !== "en") return entry.dir; + if (locale === "en") return "ltr"; + const [language = "", script] = locale.toLowerCase().split("-"); + if (script === "latn" || script === "cyrl") return "ltr"; + return RTL.has(language) ? "rtl" : "ltr"; +} + +/** ICU simple arguments only (§1.5): `{name}` is replaced, anything else is left as it is. */ +export function fillMessage(message: string, params: Readonly>): string { + return message.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (all, name: string) => + Object.hasOwn(params, name) ? (params[name] as string) : all, + ); +} + +function wordsOf(state: SiteState, page: StatusPageInput["page"], m: StatusMessages) { + switch (state) { + case "not-published": + return m.notPublished; + case "maintenance": + return page === "coming-soon" ? m.comingSoon : m.maintenance; + case "unavailable": + return m.unavailable; + case "error": + return m.error; + case "preview-expired": + return m.previewExpired; + } +} + +/** "2 November, 11:00 GMT+3" in the page's locale and the site's zone (Western digits as Studio). */ +function formatUntil(at: string, timeZone: string | undefined, intl: string): string | undefined { + const t = Date.parse(at); + if (!Number.isFinite(t)) return undefined; + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "long", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }; + for (const zone of [timeZone, "UTC"]) { + try { + return new Intl.DateTimeFormat(intl, { ...options, timeZone: zone ?? "UTC" }).format(t); + } catch { + // an unknown zone: UTC next + } + } + return undefined; +} + +/** The words of a page in its language: what the theme hook gets as `strings` (§1.7). */ +export interface StatusStrings { + readonly locale: string; + readonly dir: StatusDir; + readonly title: string; + readonly body: string; + readonly builtWith: string; + readonly untilText?: string | undefined; + readonly referenceText?: string | undefined; +} + +export function statusStrings(state: SiteState, input: StatusPageInput): StatusStrings { + const locale = catalogLocale(input.locale); + const entry = STATUS_CATALOG[locale] ?? (STATUS_CATALOG.en as (typeof STATUS_CATALOG)["en"]); + const m = entry.messages; + const words = wordsOf(state, input.page, m); + const until = + state === "maintenance" && input.until + ? formatUntil(input.until.at, input.until.timeZone, entry.intl ?? locale) + : undefined; + return { + locale, + dir: entry.dir, + title: words.title, + body: words.body, + builtWith: m.builtWith, + ...(until ? { untilText: fillMessage(m.backBy, { time: until }) } : {}), + ...(state === "error" && input.rayId + ? { referenceText: fillMessage(m.reference, { reference: input.rayId }) } + : {}), + }; +} + +/** Only a same-origin media URL may be the logo (CSP `img-src 'self'`, §1.6). */ +const sameOriginLogo = (src: string | undefined) => + src && /^\/media\/[^\s"'<>]+$/.test(src) ? src : undefined; + +function documentOf(state: SiteState, input: StatusPageInput, css?: string): StatusDocument { + const s = statusStrings(state, input); + const message = state === "maintenance" ? input.message : undefined; + return { + state, + locale: s.locale, + dir: s.dir, + noindex: NOINDEX.has(state), + words: { title: s.title, body: s.body, builtWith: s.builtWith }, + siteName: state === "unavailable" ? undefined : input.site?.name, + logo: + state === "not-published" || state === "unavailable" + ? undefined + : sameOriginLogo(input.site?.logo), + ...(message?.text.trim() + ? { + message: { + text: message.text, + locale: message.locale, + dir: statusDirection(message.locale), + }, + } + : {}), + until: s.untilText, + reference: s.referenceText, + theme: state === "maintenance" || state === "preview-expired" ? input.theme : undefined, + css, + }; +} + +const NEUTRAL: StatusPageInput = { locale: "en", now: 0 }; + +/** The same document as a string, for theme hooks that only restyle it (§1.7). Never throws. */ +export function statusPageHtml( + state: SiteState, + input: StatusPageInput, + options: { css?: string | undefined } = {}, +): string { + try { + return statusDocument(documentOf(state, input, options.css)); + } catch { + return statusDocument(documentOf(state, NEUTRAL)); + } +} + +/** The headers every status response carries (§1.2), for an HTML page or a plain answer. */ +export function statusHeaders( + state: SiteState, + input: Pick, + contentType: string, + csp: string = STATUS_CSP, +): Headers { + const headers = new Headers({ + "content-type": contentType, + "cache-control": "no-store", + "cloudflare-cdn-cache-control": "no-store", + "x-cloudhal-state": state, + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + }); + const retry = retryAfter(state, input); + if (retry) headers.set("retry-after", retry); + if (NOINDEX.has(state)) headers.set("x-robots-tag", "noindex"); + if (contentType.startsWith("text/html")) headers.set("content-security-policy", csp); + return headers; +} + +export interface StatusPageOptions { + /** `HEAD` gets the headers without a body. */ + readonly method?: string | undefined; + /** + * A theme's own document (§1.7), already checked by the hook runner: it replaces the built-in + * body and gets `csp` (the site's policy) instead of the built-in page's stricter one. + */ + readonly html?: string | undefined; + readonly csp?: string | undefined; +} + +/** The built-in page: status, headers and body. Never throws. */ +export function statusPage( + state: SiteState, + input: StatusPageInput, + options: StatusPageOptions = {}, +): Response { + const html = options.html ?? statusPageHtml(state, input); + const headers = statusHeaders( + state, + input, + "text/html; charset=utf-8", + options.html ? (options.csp ?? STATUS_CSP) : STATUS_CSP, + ); + return new Response(options.method === "HEAD" ? null : html, { + status: statusCode(state), + headers, + }); +} + +/** Machine endpoints in the same states (§2.3): short plain text or JSON, same status and headers. */ +export function statusPlain( + state: SiteState, + input: StatusPageInput, + as: "text" | "json", + options: { method?: string | undefined } = {}, +): Response { + const s = statusStrings(state, input); + const body = + as === "json" + ? JSON.stringify({ error: state, message: `${s.title}. ${s.body}` }) + : `${s.title}. ${s.body}\n`; + const headers = statusHeaders( + state, + input, + as === "json" ? "application/json; charset=utf-8" : "text/plain; charset=utf-8", + ); + return new Response(options.method === "HEAD" ? null : body, { + status: statusCode(state), + headers, + }); +} + +/** The owner's message as the hook sees it (`messageHtml`, §1.7). */ +export { messageHtml } from "./status-html.ts"; diff --git a/packages/astro-renderer/src/status-serve.ts b/packages/astro-renderer/src/status-serve.ts new file mode 100644 index 00000000..9a117952 --- /dev/null +++ b/packages/astro-renderer/src/status-serve.ts @@ -0,0 +1,112 @@ +/** + * What the Site Worker entry and the dev middleware need around `statusPage` (RFC-0043 §1.4, + * §1.6, §1.8): which paths get a page rather than the theme's own answer, the page's locale from + * the request's host or path prefix, and the site data each state may show. Worker-safe. + */ +import type { Site } from "@cloudhal/core-contracts"; +import { isCommentsPath } from "./comments.ts"; +import { localeRequest } from "./locales.ts"; +import { matchMediaRoute } from "./media-route.ts"; +import type { SiteState, StatusPageInput, StatusTheme } from "./status-page.ts"; + +/** Feeds, sitemaps, `llms.txt`, search JSON, manifests: machines read these (§2.3). */ +export const MACHINE_PATH = /\.(?:xml|txt|json|rss|atom|webmanifest)$/i; + +/** + * True for a path that gets the status page before the first publish (§2.3): everything a + * person opens in a browser. `robots.txt`, media, static assets, `/.well-known/`, the + * operator endpoints, comment endpoints and machine paths keep their own answers. + */ +export function isStatusPagePath(pathname: string): boolean { + if (pathname === "/robots.txt") return false; + if (matchMediaRoute(pathname)) return false; + if (/^\/(?:_astro|_cloudhal|__cloudhal|\.well-known)\//.test(pathname)) return false; + if (isCommentsPath(pathname)) return false; + return !MACHINE_PATH.test(pathname); +} + +/** What choosing the locale needs of a site (`localeRequest` reads nothing else). */ +export interface LocaleSite { + readonly locales: readonly string[]; + readonly defaultLocale: string; + readonly localization?: Site["localization"] | undefined; +} + +/** The path without trailing slashes (`/` for the root), in one linear pass: request text. */ +export function trimTrailingSlashes(pathname: string): string { + let end = pathname.length; + while (end > 0 && pathname.charCodeAt(end - 1) === 47) end--; + return end === 0 ? "/" : pathname.slice(0, end); +} + +/** §1.4: the locale of the request's host or path prefix when it is the site's, else the default. */ +export function statusLocale(site: LocaleSite, host: string, pathname: string): string { + const path = trimTrailingSlashes(pathname); + try { + return localeRequest(site as Site, host, path).locale; + } catch { + return site.defaultLocale || "en"; + } +} + +const HEX = /^#[0-9a-f]{6}$/i; +const FONTS: Record = { + editorial: "serif", + classic: "serif", + technical: "mono", + system: "sans", + modern: "sans", + humanist: "sans", +}; + +/** + * §1.3: the site's look as the status page may use it — `brand` (else `seed`), `surface` and + * `text` when they are `#rrggbb`, and the `typePairing` choice as a system font stack. Read from + * the published snapshot's site node only; everything else in `theme.config` is ignored. + */ +export function statusTheme(site: Pick): StatusTheme | undefined { + const config = (site.theme.config ?? {}) as Record; + const color = (key: string) => { + const v = config[key]; + return typeof v === "string" && HEX.test(v) ? v.toLowerCase() : undefined; + }; + const pairing = config.typePairing; + const theme: StatusTheme = { + brand: color("brand") ?? color("seed"), + surface: color("surface"), + text: color("text"), + font: typeof pairing === "string" ? FONTS[pairing] : undefined, + }; + return Object.values(theme).some((v) => v !== undefined) ? theme : undefined; +} + +/** The site's logo when it is a same-origin media URL (§1.6). */ +export function statusLogo(site: Pick): string | undefined { + const src = site.brand?.logo?.src; + return src?.startsWith("/media/") ? src : undefined; +} + +/** + * §1.6: the site data a state may show. `live` is the site node as it is now (the name only); + * `published` the published snapshot's, which also brings the logo and the theme tokens. + */ +export function statusInput( + state: SiteState, + sites: { live?: Site | undefined; published?: Site | undefined }, + request: { host: string; pathname: string }, + now: number, +): StatusPageInput { + const site = sites.published ?? sites.live; + const locale = site ? statusLocale(site, request.host, request.pathname) : "en"; + if (state === "unavailable" || state === "error" || !site) return { locale, now }; + const showsBrand = sites.published !== undefined && state !== "not-published"; + return { + locale, + now, + site: { + name: site.name, + ...(showsBrand && sites.published ? { logo: statusLogo(sites.published) } : {}), + }, + ...(showsBrand && sites.published ? { theme: statusTheme(sites.published) } : {}), + }; +} diff --git a/packages/astro-renderer/src/status-strings/ar.ts b/packages/astro-renderer/src/status-strings/ar.ts new file mode 100644 index 00000000..bf06b450 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/ar.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): ar status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: b5c519cedb1faeba +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "قريبًا", + body: "لم يُنشر هذا الموقع بعد. عُد للزيارة قريبًا.", + }, + maintenance: { + title: "سنعود قريبًا", + body: "هذا الموقع قيد الصيانة. شكرًا لصبرك.", + }, + comingSoon: { + title: "قريبًا", + body: "لم يُفتح هذا الموقع بعد. عُد للزيارة قريبًا.", + }, + unavailable: { + title: "غير متاح مؤقتًا", + body: "يتعذر الوصول إلى هذا الموقع الآن. يُرجى المحاولة مجددًا بعد بضع دقائق.", + }, + error: { + title: "حدث خطأ ما", + body: "تعذر عرض هذه الصفحة. يُرجى المحاولة مجددًا بعد قليل.", + }, + previewExpired: { + title: "انتهت صلاحية رابط المعاينة هذا", + body: "اطلب رابطًا جديدًا لعرض هذا الإصدار.", + }, + backBy: "نعود بحلول {time}", + reference: "المرجع: {reference}", + builtWith: "صُنع باستخدام Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/catalog.ts b/packages/astro-renderer/src/status-strings/catalog.ts new file mode 100644 index 00000000..6f0c8d62 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/catalog.ts @@ -0,0 +1,52 @@ +// Generated by `pnpm i18n:import` (ADR-0034, RFC-0043 §1.5): every registered language's +// status-page messages with its writing direction and `Intl` tag from Studio's locale registry. +// Do not edit: add a language to `apps/studio/src/i18n/locales.ts` and import its catalogs. + +import { messages as ar } from "./ar.ts"; +import { messages as de } from "./de.ts"; +import { en, type StatusMessages } from "./en.ts"; +import { messages as es } from "./es.ts"; +import { messages as fa } from "./fa.ts"; +import { messages as fr } from "./fr.ts"; +import { messages as he } from "./he.ts"; +import { messages as hi } from "./hi.ts"; +import { messages as id } from "./id.ts"; +import { messages as it } from "./it.ts"; +import { messages as ja } from "./ja.ts"; +import { messages as ko } from "./ko.ts"; +import { messages as nl } from "./nl.ts"; +import { messages as pl } from "./pl.ts"; +import { messages as pt_BR } from "./pt-BR.ts"; +import { messages as ru } from "./ru.ts"; +import { messages as tr } from "./tr.ts"; +import { messages as uk } from "./uk.ts"; +import { messages as zh_Hans } from "./zh-Hans.ts"; + +export interface StatusCatalogEntry { + readonly messages: StatusMessages; + readonly dir: "ltr" | "rtl"; + /** The tag `Intl` formats with when it differs from the locale (Western digits, ADR-0033). */ + readonly intl?: string; +} + +export const STATUS_CATALOG: Readonly> = { + en: { messages: en, dir: "ltr" }, + tr: { messages: tr, dir: "ltr" }, + de: { messages: de, dir: "ltr" }, + es: { messages: es, dir: "ltr" }, + fr: { messages: fr, dir: "ltr" }, + it: { messages: it, dir: "ltr" }, + "pt-BR": { messages: pt_BR, dir: "ltr" }, + nl: { messages: nl, dir: "ltr" }, + pl: { messages: pl, dir: "ltr" }, + ru: { messages: ru, dir: "ltr" }, + uk: { messages: uk, dir: "ltr" }, + ja: { messages: ja, dir: "ltr" }, + ko: { messages: ko, dir: "ltr" }, + "zh-Hans": { messages: zh_Hans, dir: "ltr" }, + hi: { messages: hi, dir: "ltr" }, + id: { messages: id, dir: "ltr" }, + ar: { messages: ar, dir: "rtl", intl: "ar-u-nu-latn" }, + fa: { messages: fa, dir: "rtl", intl: "fa-u-nu-latn" }, + he: { messages: he, dir: "rtl" }, +}; diff --git a/packages/astro-renderer/src/status-strings/de.ts b/packages/astro-renderer/src/status-strings/de.ts new file mode 100644 index 00000000..80d876d9 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/de.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): de status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 96c78422069956fb +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Demnächst", + body: "Diese Website ist noch nicht veröffentlicht. Schauen Sie bald wieder vorbei.", + }, + maintenance: { + title: "Wir sind bald zurück", + body: "Diese Website wird gerade gewartet. Vielen Dank für Ihre Geduld.", + }, + comingSoon: { + title: "Demnächst", + body: "Diese Website ist noch nicht geöffnet. Schauen Sie bald wieder vorbei.", + }, + unavailable: { + title: "Vorübergehend nicht erreichbar", + body: "Diese Website ist gerade nicht erreichbar. Bitte versuchen Sie es in ein paar Minuten erneut.", + }, + error: { + title: "Etwas ist schiefgelaufen", + body: "Diese Seite konnte nicht angezeigt werden. Bitte versuchen Sie es gleich noch einmal.", + }, + previewExpired: { + title: "Dieser Vorschaulink ist abgelaufen", + body: "Bitten Sie um einen neuen Link, um diese Version zu sehen.", + }, + backBy: "Zurück bis {time}", + reference: "Referenz: {reference}", + builtWith: "Erstellt mit Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/en.ts b/packages/astro-renderer/src/status-strings/en.ts new file mode 100644 index 00000000..d3ec845d --- /dev/null +++ b/packages/astro-renderer/src/status-strings/en.ts @@ -0,0 +1,47 @@ +/** + * The words of the status pages (RFC-0043 §1.5): English is the source of truth for the keys, + * edited here by hand; every other registered language is generated by `pnpm i18n:import` from + * `.i18n//status.json` (ADR-0034) and `catalog.ts` lists them all. Messages are ICU + * MessageFormat 1 restricted to simple `{argument}`s: nothing here counts or selects, so the + * renderer substitutes them itself and no compiler ships in the site Worker. + */ +export const en = { + /** Before the first publish (503): the site's name may sit above it. */ + notPublished: { + title: "Coming soon", + body: "This site isn't published yet. Check back soon.", + }, + /** Maintenance mode with the `maintenance` wording (503). */ + maintenance: { + title: "We'll be back soon", + body: "This site is down for maintenance. Thanks for your patience.", + }, + /** Maintenance mode with the `coming-soon` wording: a published site kept private until launch (503). */ + comingSoon: { + title: "Coming soon", + body: "This site isn't open yet. Check back soon.", + }, + /** The Worker serves no site, or its store cannot be read (503). */ + unavailable: { + title: "Temporarily unavailable", + body: "This site can't be reached right now. Please try again in a few minutes.", + }, + /** A render failure (500); the page shows a reference id a visitor can quote. */ + error: { + title: "Something went wrong", + body: "This page couldn't be shown. Please try again in a moment.", + }, + /** An expired, forged or otherwise invalid preview link (410). */ + previewExpired: { + title: "This preview link has expired", + body: "Ask for a new link to see this version.", + }, + /** When maintenance ends; {time} is a date and time in the site's time zone. */ + backBy: "Back by {time}", + /** The error page's request id; {reference} is the id a visitor can quote to the owner. */ + reference: "Reference: {reference}", + /** The footer of the built-in page. */ + builtWith: "Built with Cloudhal", +}; + +export type StatusMessages = typeof en; diff --git a/packages/astro-renderer/src/status-strings/es.ts b/packages/astro-renderer/src/status-strings/es.ts new file mode 100644 index 00000000..e06f1ecf --- /dev/null +++ b/packages/astro-renderer/src/status-strings/es.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): es status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 328d0dacfe8b0e76 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Próximamente", + body: "Este sitio aún no está publicado. Vuelve pronto.", + }, + maintenance: { + title: "Volveremos pronto", + body: "Este sitio está en mantenimiento. Gracias por tu paciencia.", + }, + comingSoon: { + title: "Próximamente", + body: "Este sitio aún no está abierto. Vuelve pronto.", + }, + unavailable: { + title: "Temporalmente no disponible", + body: "No es posible acceder a este sitio en este momento. Inténtalo de nuevo en unos minutos.", + }, + error: { + title: "Algo salió mal", + body: "No se pudo mostrar esta página. Inténtalo de nuevo en un momento.", + }, + previewExpired: { + title: "Este enlace de vista previa ha caducado", + body: "Pide un enlace nuevo para ver esta versión.", + }, + backBy: "Volvemos el {time}", + reference: "Referencia: {reference}", + builtWith: "Creado con Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/fa.ts b/packages/astro-renderer/src/status-strings/fa.ts new file mode 100644 index 00000000..6132131c --- /dev/null +++ b/packages/astro-renderer/src/status-strings/fa.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): fa status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 9902597705f57558 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "به‌زودی", + body: "این سایت هنوز منتشر نشده است. به‌زودی دوباره سر بزنید.", + }, + maintenance: { + title: "به‌زودی برمی‌گردیم", + body: "این سایت در حال تعمیر و نگهداری است. از شکیبایی شما سپاسگزاریم.", + }, + comingSoon: { + title: "به‌زودی", + body: "این سایت هنوز باز نشده است. به‌زودی دوباره سر بزنید.", + }, + unavailable: { + title: "موقتاً در دسترس نیست", + body: "در حال حاضر نمی‌توان به این سایت دسترسی داشت. لطفاً چند دقیقه دیگر دوباره تلاش کنید.", + }, + error: { + title: "مشکلی پیش آمد", + body: "این صفحه نمایش داده نشد. لطفاً کمی بعد دوباره تلاش کنید.", + }, + previewExpired: { + title: "این پیوند پیش‌نمایش منقضی شده است", + body: "برای دیدن این نسخه، پیوند تازه‌ای درخواست کنید.", + }, + backBy: "تا {time} برمی‌گردیم", + reference: "شناسه پیگیری: {reference}", + builtWith: "ساخته‌شده با Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/fr.ts b/packages/astro-renderer/src/status-strings/fr.ts new file mode 100644 index 00000000..4c3dcb41 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/fr.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): fr status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 3766e010edaa51e7 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Bientôt disponible", + body: "Ce site n'est pas encore publié. Revenez bientôt.", + }, + maintenance: { + title: "Nous revenons bientôt", + body: "Ce site est en maintenance. Merci de votre patience.", + }, + comingSoon: { + title: "Bientôt disponible", + body: "Ce site n'est pas encore ouvert. Revenez bientôt.", + }, + unavailable: { + title: "Temporairement indisponible", + body: "Ce site est inaccessible pour le moment. Veuillez réessayer dans quelques minutes.", + }, + error: { + title: "Un problème est survenu", + body: "Cette page n'a pas pu être affichée. Veuillez réessayer dans un instant.", + }, + previewExpired: { + title: "Ce lien d'aperçu a expiré", + body: "Demandez un nouveau lien pour voir cette version.", + }, + backBy: "De retour le {time}", + reference: "Référence : {reference}", + builtWith: "Créé avec Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/he.ts b/packages/astro-renderer/src/status-strings/he.ts new file mode 100644 index 00000000..b9b42885 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/he.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): he status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 14a7126c4a0192c8 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "בקרוב", + body: "האתר הזה עדיין לא פורסם. חזרו לבקר בקרוב.", + }, + maintenance: { + title: "נחזור בקרוב", + body: "האתר הזה נמצא בתחזוקה. תודה על הסבלנות.", + }, + comingSoon: { + title: "בקרוב", + body: "האתר הזה עדיין לא נפתח. חזרו לבקר בקרוב.", + }, + unavailable: { + title: "לא זמין באופן זמני", + body: "אי אפשר להגיע לאתר כרגע. נסו שוב בעוד כמה דקות.", + }, + error: { + title: "משהו השתבש", + body: "לא ניתן היה להציג את הדף הזה. נסו שוב בעוד רגע.", + }, + previewExpired: { + title: "תוקף קישור התצוגה המקדימה פג", + body: "בקשו קישור חדש כדי לראות את הגרסה הזו.", + }, + backBy: "נחזור עד {time}", + reference: "מספר אסמכתה: {reference}", + builtWith: "נבנה עם Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/hi.ts b/packages/astro-renderer/src/status-strings/hi.ts new file mode 100644 index 00000000..6f3eb5b8 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/hi.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): hi status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: a36645c8efdccd2c +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "जल्द आ रहा है", + body: "यह साइट अभी प्रकाशित नहीं हुई है। जल्द ही दोबारा देखें।", + }, + maintenance: { + title: "हम जल्द वापस आएंगे", + body: "यह साइट रखरखाव के लिए बंद है। आपके धैर्य के लिए धन्यवाद।", + }, + comingSoon: { + title: "जल्द आ रहा है", + body: "यह साइट अभी खुली नहीं है। जल्द ही दोबारा देखें।", + }, + unavailable: { + title: "अस्थायी रूप से अनुपलब्ध", + body: "इस साइट तक अभी नहीं पहुँचा जा सकता। कृपया कुछ मिनट बाद फिर से कोशिश करें।", + }, + error: { + title: "कुछ गलत हो गया", + body: "यह पेज नहीं दिखाया जा सका। कृपया थोड़ी देर बाद फिर से कोशिश करें।", + }, + previewExpired: { + title: "यह पूर्वावलोकन लिंक समाप्त हो गया है", + body: "यह संस्करण देखने के लिए नया लिंक माँगें।", + }, + backBy: "{time} तक वापस", + reference: "संदर्भ: {reference}", + builtWith: "Cloudhal से बनाया गया", +}; diff --git a/packages/astro-renderer/src/status-strings/id.ts b/packages/astro-renderer/src/status-strings/id.ts new file mode 100644 index 00000000..c4ad5478 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/id.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): id status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 4f61cc5462b5cc67 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Segera hadir", + body: "Situs ini belum dipublikasikan. Kunjungi lagi nanti.", + }, + maintenance: { + title: "Kami segera kembali", + body: "Situs ini sedang dalam pemeliharaan. Terima kasih atas kesabaran Anda.", + }, + comingSoon: { + title: "Segera hadir", + body: "Situs ini belum dibuka. Kunjungi lagi nanti.", + }, + unavailable: { + title: "Sementara tidak tersedia", + body: "Situs ini tidak dapat diakses saat ini. Coba lagi dalam beberapa menit.", + }, + error: { + title: "Terjadi kesalahan", + body: "Halaman ini tidak dapat ditampilkan. Coba lagi sebentar lagi.", + }, + previewExpired: { + title: "Tautan pratinjau ini sudah kedaluwarsa", + body: "Minta tautan baru untuk melihat versi ini.", + }, + backBy: "Kembali pada {time}", + reference: "Referensi: {reference}", + builtWith: "Dibuat dengan Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/it.ts b/packages/astro-renderer/src/status-strings/it.ts new file mode 100644 index 00000000..1a8f65aa --- /dev/null +++ b/packages/astro-renderer/src/status-strings/it.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): it status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 9e08b82495295521 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Prossimamente", + body: "Questo sito non è ancora pubblicato. Torna a trovarci presto.", + }, + maintenance: { + title: "Torniamo presto", + body: "Questo sito è in manutenzione. Grazie per la pazienza.", + }, + comingSoon: { + title: "Prossimamente", + body: "Questo sito non è ancora aperto. Torna a trovarci presto.", + }, + unavailable: { + title: "Temporaneamente non disponibile", + body: "Al momento non è possibile raggiungere questo sito. Riprova tra qualche minuto.", + }, + error: { + title: "Qualcosa è andato storto", + body: "Non è stato possibile mostrare questa pagina. Riprova tra poco.", + }, + previewExpired: { + title: "Questo link di anteprima è scaduto", + body: "Chiedi un nuovo link per vedere questa versione.", + }, + backBy: "Di ritorno entro {time}", + reference: "Riferimento: {reference}", + builtWith: "Creato con Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/ja.ts b/packages/astro-renderer/src/status-strings/ja.ts new file mode 100644 index 00000000..9e29c170 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/ja.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): ja status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 58c8bbe001b9fdaf +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "近日公開", + body: "このサイトはまだ公開されていません。またのお越しをお待ちしています。", + }, + maintenance: { + title: "まもなく再開します", + body: "このサイトはメンテナンス中です。しばらくお待ちください。", + }, + comingSoon: { + title: "近日公開", + body: "このサイトはまだオープンしていません。またのお越しをお待ちしています。", + }, + unavailable: { + title: "一時的にご利用いただけません", + body: "現在このサイトに接続できません。数分後にもう一度お試しください。", + }, + error: { + title: "問題が発生しました", + body: "このページを表示できませんでした。しばらくしてからもう一度お試しください。", + }, + previewExpired: { + title: "このプレビューリンクは期限切れです", + body: "このバージョンを見るには、新しいリンクを依頼してください。", + }, + backBy: "{time} に再開予定", + reference: "参照番号: {reference}", + builtWith: "Cloudhal で作成", +}; diff --git a/packages/astro-renderer/src/status-strings/ko.ts b/packages/astro-renderer/src/status-strings/ko.ts new file mode 100644 index 00000000..e86fdb69 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/ko.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): ko status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 9e81d1a04a442f15 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "곧 공개됩니다", + body: "이 사이트는 아직 게시되지 않았습니다. 곧 다시 방문해 주세요.", + }, + maintenance: { + title: "곧 돌아오겠습니다", + body: "이 사이트는 점검 중입니다. 기다려 주셔서 감사합니다.", + }, + comingSoon: { + title: "곧 공개됩니다", + body: "이 사이트는 아직 열리지 않았습니다. 곧 다시 방문해 주세요.", + }, + unavailable: { + title: "일시적으로 이용할 수 없습니다", + body: "지금은 이 사이트에 접속할 수 없습니다. 몇 분 후에 다시 시도해 주세요.", + }, + error: { + title: "문제가 발생했습니다", + body: "이 페이지를 표시할 수 없습니다. 잠시 후 다시 시도해 주세요.", + }, + previewExpired: { + title: "이 미리보기 링크가 만료되었습니다", + body: "이 버전을 보려면 새 링크를 요청하세요.", + }, + backBy: "{time}에 다시 시작합니다", + reference: "참조 번호: {reference}", + builtWith: "Cloudhal로 제작", +}; diff --git a/packages/astro-renderer/src/status-strings/nl.ts b/packages/astro-renderer/src/status-strings/nl.ts new file mode 100644 index 00000000..1b9b8e4f --- /dev/null +++ b/packages/astro-renderer/src/status-strings/nl.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): nl status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: fe371781027a8452 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Binnenkort", + body: "Deze site is nog niet gepubliceerd. Kom binnenkort terug.", + }, + maintenance: { + title: "We zijn snel terug", + body: "Deze site is in onderhoud. Bedankt voor uw geduld.", + }, + comingSoon: { + title: "Binnenkort", + body: "Deze site is nog niet open. Kom binnenkort terug.", + }, + unavailable: { + title: "Tijdelijk niet beschikbaar", + body: "Deze site is op dit moment niet bereikbaar. Probeer het over een paar minuten opnieuw.", + }, + error: { + title: "Er is iets misgegaan", + body: "Deze pagina kon niet worden getoond. Probeer het zo meteen opnieuw.", + }, + previewExpired: { + title: "Deze voorbeeldlink is verlopen", + body: "Vraag een nieuwe link aan om deze versie te bekijken.", + }, + backBy: "Terug om {time}", + reference: "Referentie: {reference}", + builtWith: "Gemaakt met Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/pl.ts b/packages/astro-renderer/src/status-strings/pl.ts new file mode 100644 index 00000000..9bf495d4 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/pl.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): pl status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: a0de1472a18c209e +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Już wkrótce", + body: "Ta strona nie została jeszcze opublikowana. Zajrzyj wkrótce.", + }, + maintenance: { + title: "Wkrótce wracamy", + body: "Ta strona jest w trakcie konserwacji. Dziękujemy za cierpliwość.", + }, + comingSoon: { + title: "Już wkrótce", + body: "Ta strona nie jest jeszcze otwarta. Zajrzyj wkrótce.", + }, + unavailable: { + title: "Tymczasowo niedostępna", + body: "Nie można teraz połączyć się z tą stroną. Spróbuj ponownie za kilka minut.", + }, + error: { + title: "Coś poszło nie tak", + body: "Nie udało się wyświetlić tej strony. Spróbuj ponownie za chwilę.", + }, + previewExpired: { + title: "Ten link do podglądu wygasł", + body: "Poproś o nowy link, aby zobaczyć tę wersję.", + }, + backBy: "Wracamy {time}", + reference: "Numer referencyjny: {reference}", + builtWith: "Stworzone z Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/pt-BR.ts b/packages/astro-renderer/src/status-strings/pt-BR.ts new file mode 100644 index 00000000..0f0a7d24 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/pt-BR.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): pt-BR status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 85bbd6c18a5d4a89 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Em breve", + body: "Este site ainda não foi publicado. Volte em breve.", + }, + maintenance: { + title: "Voltamos em breve", + body: "Este site está em manutenção. Obrigado pela paciência.", + }, + comingSoon: { + title: "Em breve", + body: "Este site ainda não está aberto. Volte em breve.", + }, + unavailable: { + title: "Temporariamente indisponível", + body: "Não é possível acessar este site agora. Tente novamente em alguns minutos.", + }, + error: { + title: "Algo deu errado", + body: "Não foi possível mostrar esta página. Tente novamente em instantes.", + }, + previewExpired: { + title: "Este link de pré-visualização expirou", + body: "Peça um novo link para ver esta versão.", + }, + backBy: "De volta em {time}", + reference: "Referência: {reference}", + builtWith: "Feito com Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/ru.ts b/packages/astro-renderer/src/status-strings/ru.ts new file mode 100644 index 00000000..208b5fc9 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/ru.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): ru status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 2ea252bc0f9d24f4 +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Скоро", + body: "Этот сайт ещё не опубликован. Загляните позже.", + }, + maintenance: { + title: "Мы скоро вернёмся", + body: "На сайте ведутся технические работы. Спасибо за терпение.", + }, + comingSoon: { + title: "Скоро", + body: "Этот сайт ещё не открыт. Загляните позже.", + }, + unavailable: { + title: "Временно недоступен", + body: "Сайт сейчас недоступен. Попробуйте снова через несколько минут.", + }, + error: { + title: "Что-то пошло не так", + body: "Не удалось показать эту страницу. Попробуйте снова чуть позже.", + }, + previewExpired: { + title: "Срок действия ссылки на предпросмотр истёк", + body: "Запросите новую ссылку, чтобы увидеть эту версию.", + }, + backBy: "Вернёмся к {time}", + reference: "Код обращения: {reference}", + builtWith: "Создано с помощью Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/tr.ts b/packages/astro-renderer/src/status-strings/tr.ts new file mode 100644 index 00000000..7c12afc7 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/tr.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): tr status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: ca071f0a31b002dd +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Çok yakında", + body: "Bu site henüz yayımlanmadı. Yakında tekrar bakın.", + }, + maintenance: { + title: "Yakında geri döneceğiz", + body: "Bu site bakım nedeniyle kapalı. Sabrınız için teşekkürler.", + }, + comingSoon: { + title: "Çok yakında", + body: "Bu site henüz açılmadı. Yakında tekrar bakın.", + }, + unavailable: { + title: "Geçici olarak erişilemiyor", + body: "Bu siteye şu anda ulaşılamıyor. Lütfen birkaç dakika sonra tekrar deneyin.", + }, + error: { + title: "Bir sorun oluştu", + body: "Bu sayfa gösterilemedi. Lütfen birazdan tekrar deneyin.", + }, + previewExpired: { + title: "Bu önizleme bağlantısının süresi doldu", + body: "Bu sürümü görmek için yeni bir bağlantı isteyin.", + }, + backBy: "{time} itibarıyla geri döneceğiz", + reference: "Referans: {reference}", + builtWith: "Cloudhal ile yapıldı", +}; diff --git a/packages/astro-renderer/src/status-strings/uk.ts b/packages/astro-renderer/src/status-strings/uk.ts new file mode 100644 index 00000000..7d92ffa5 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/uk.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): uk status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 4c4d6784cfecafac +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "Незабаром", + body: "Цей сайт ще не опубліковано. Завітайте пізніше.", + }, + maintenance: { + title: "Ми скоро повернемося", + body: "На сайті тривають технічні роботи. Дякуємо за терпіння.", + }, + comingSoon: { + title: "Незабаром", + body: "Цей сайт ще не відкрито. Завітайте пізніше.", + }, + unavailable: { + title: "Тимчасово недоступний", + body: "Сайт зараз недоступний. Спробуйте ще раз за кілька хвилин.", + }, + error: { + title: "Щось пішло не так", + body: "Не вдалося показати цю сторінку. Спробуйте ще раз за мить.", + }, + previewExpired: { + title: "Термін дії посилання на попередній перегляд минув", + body: "Попросіть нове посилання, щоб побачити цю версію.", + }, + backBy: "Повернемося до {time}", + reference: "Код звернення: {reference}", + builtWith: "Створено за допомогою Cloudhal", +}; diff --git a/packages/astro-renderer/src/status-strings/zh-Hans.ts b/packages/astro-renderer/src/status-strings/zh-Hans.ts new file mode 100644 index 00000000..50f62af3 --- /dev/null +++ b/packages/astro-renderer/src/status-strings/zh-Hans.ts @@ -0,0 +1,34 @@ +// Generated by `pnpm i18n:import` (ADR-0034): zh-Hans status page messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: f2bdbed0cee0a7bb +import type { StatusMessages } from "./en.ts"; + +export const messages: StatusMessages = { + notPublished: { + title: "即将上线", + body: "此网站尚未发布,请稍后再来。", + }, + maintenance: { + title: "我们很快回来", + body: "此网站正在维护中,感谢您的耐心等待。", + }, + comingSoon: { + title: "即将上线", + body: "此网站尚未开放,请稍后再来。", + }, + unavailable: { + title: "暂时无法访问", + body: "目前无法连接此网站,请几分钟后重试。", + }, + error: { + title: "出了点问题", + body: "无法显示此页面,请稍后重试。", + }, + previewExpired: { + title: "此预览链接已过期", + body: "请索取新的链接以查看此版本。", + }, + backBy: "预计 {time} 恢复", + reference: "参考编号:{reference}", + builtWith: "由 Cloudhal 构建", +}; diff --git a/packages/astro-renderer/test/status-page.test.ts b/packages/astro-renderer/test/status-page.test.ts new file mode 100644 index 00000000..03c11683 --- /dev/null +++ b/packages/astro-renderer/test/status-page.test.ts @@ -0,0 +1,510 @@ +/** + * RFC-0043 §1: the status pages as a pure function — every state's status, headers and + * `noindex`; the locale order and the catalog match (RTL included); the 8 KB and CSP budgets + * in every language; the owner's message escaped and never linked; the theme tokens typed; the + * machine variant; and the theme hook with each of its fallbacks. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Site } from "@cloudhal/core-contracts"; +import { describe, expect, it } from "vitest"; +import { + renderStatus, + resetHookFailures, + type StatusContext, + type StatusPageHook, + themeStatusHook, +} from "../src/status-hook.ts"; +import { contrastRatio } from "../src/status-html.ts"; +import { + catalogLocale, + fillMessage, + HOOK_HTML_LIMIT, + SITE_STATES, + type SiteState, + STATUS_CSP, + STATUS_PAGE_LIMIT, + statusDirection, + statusPage, + statusPageHtml, + statusPlain, + statusStrings, +} from "../src/status-page.ts"; +import { + isStatusPagePath, + statusInput, + statusLocale, + statusTheme, + trimTrailingSlashes, +} from "../src/status-serve.ts"; +import { STATUS_CATALOG } from "../src/status-strings/catalog.ts"; +import { en } from "../src/status-strings/en.ts"; + +const NOW = Date.parse("2026-11-08T10:00:00Z"); +const here = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Studio's locale registry (`apps/studio/src/i18n/locales.ts`), read as text: the catalog must + * list the same languages with the same direction and `Intl` tag, and the renderer's tsconfig + * cannot import across the apps/ boundary. + */ +function studioRegistry(): Record { + const source = readFileSync( + path.resolve(here, "../../../apps/studio/src/i18n/locales.ts"), + "utf8", + ); + const body = source.slice(source.indexOf("LOCALE_REGISTRY = {"), source.indexOf("} as const")); + const out: Record = {}; + for (const m of body.matchAll( + /^\s+"?([A-Za-z-]+)"?: \{ name: "[^"]*", dir: "(ltr|rtl)"[^}]*?(?:intl: "([^"]+)")?\s*\},?$/gm, + )) + out[m[1] as string] = { dir: m[2] as string, ...(m[3] ? { intl: m[3] } : {}) }; + return out; +} +const base = { locale: "en", now: NOW }; +const bytes = (s: string) => new TextEncoder().encode(s).length; + +describe("statusPage: states, codes and headers (§1.2)", () => { + const expected: Record = { + "not-published": { status: 503, retry: "3600", noindex: true }, + maintenance: { status: 503, retry: "3600", noindex: false }, + unavailable: { status: 503, retry: "300", noindex: false }, + error: { status: 500, noindex: false }, + "preview-expired": { status: 410, noindex: true }, + }; + + for (const state of SITE_STATES) { + it(`${state}: ${expected[state].status}`, async () => { + const res = statusPage(state, { ...base, rayId: "ray-1" }); + const e = expected[state]; + expect(res.status).toBe(e.status); + expect(res.headers.get("retry-after")).toBe(e.retry ?? null); + expect(res.headers.get("x-robots-tag")).toBe(e.noindex ? "noindex" : null); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(res.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); + expect(res.headers.get("x-cloudhal-state")).toBe(state); + expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(res.headers.get("content-security-policy")).toBe(STATUS_CSP); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + const html = await res.text(); + expect(html).toMatch(/^/); + expect(html).toContain(`data-state="${state}"`); + expect(html.includes('')).toBe(e.noindex); + // exactly one h1, no scripts, no requests + expect(html.match(/

{ + const res = statusPage("not-published", base, { method: "HEAD" }); + expect(res.status).toBe(503); + expect(res.headers.get("retry-after")).toBe("3600"); + expect(await res.text()).toBe(""); + }); + + it("maintenance: Retry-After counts to the end, at least 60 s, 3600 without an end", () => { + const until = (seconds: number) => ({ + at: new Date(NOW + seconds * 1000).toISOString(), + }); + const retry = (u?: { at: string }) => + statusPage("maintenance", { ...base, until: u }).headers.get("retry-after"); + expect(retry(until(600))).toBe("600"); + expect(retry(until(10))).toBe("60"); + expect(retry(until(-100))).toBe("60"); + expect(retry()).toBe("3600"); + expect(retry({ at: "not a date" })).toBe("3600"); + }); + + it("error shows only the reference id", async () => { + const html = await statusPage("error", { + ...base, + rayId: "8f1c