diff --git a/.gitleaks.toml b/.gitleaks.toml
index 85eb6c3..8680aa3 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -15,3 +15,18 @@ regexes = [
'''^\s*"packages/connect-web/src/assert-fetch-api\.ts": "b577e990dcf088f942a1dcf66405aab52c7d6f72bed2757c64e213e32ce91dac",?$''',
'''^\s*"packages/shared/ReactFlightPropertyAccess\.js": "b80258eb57aa5de9d4386788424c10b259b87b76f84498605bf10f0b0dd7c793",?$''',
]
+
+[[allowlists]]
+description = "Reviewed public source hashes in the immutable browser resource r8 profile"
+condition = "AND"
+targetRules = ["generic-api-key"]
+regexTarget = "line"
+paths = ['''^eng/provenance/profiles/browser-resources-r8\.json$''']
+regexes = [
+ '''^\s*"node_modules/@bufbuild/protobuf/dist/esm/wkt/gen/google/protobuf/api_pb\.js": "73e489001027c703bc0224ae73f78ecefe028e88284bd06952e8603c3d81472c",?$''',
+ '''^\s*"node_modules/@connectrpc/connect-web/dist/esm/assert-fetch-api\.js": "bd56033776818aaa82959c12561dd084d3a730180e6e8f1e681f5d3d439b6474",?$''',
+ '''^\s*"packages/protobuf/src/wkt/gen/google/protobuf/api_pb\.ts": "538052ccd9a02c464702000dfc92a5e7cc01b7b5f55970b14af39662c7b46ae5",?$''',
+ '''^\s*"packages/protobuf/src/wkt/gen/google/protobuf/api_pb\.ts": "440d9371d9b04a8025e6f89d0041c2a54c7c926abedfbd24cef1432ec3fc38a4",?$''',
+ '''^\s*"packages/connect-web/src/assert-fetch-api\.ts": "b577e990dcf088f942a1dcf66405aab52c7d6f72bed2757c64e213e32ce91dac",?$''',
+ '''^\s*"packages/shared/ReactFlightPropertyAccess\.js": "b80258eb57aa5de9d4386788424c10b259b87b76f84498605bf10f0b0dd7c793",?$''',
+]
diff --git a/apps/site/app/root.tsx b/apps/site/app/root.tsx
index 50e5c80..1d891ec 100644
--- a/apps/site/app/root.tsx
+++ b/apps/site/app/root.tsx
@@ -9,12 +9,15 @@ import {
Outlet,
Scripts,
ScrollRestoration,
+ useLocation,
useRouteError,
} from "react-router";
+import { localeForPath } from "./site-manifest";
export function Layout({ children }: { children: ReactNode }) {
+ const location = useLocation();
return (
-
+
diff --git a/apps/site/app/routes.ts b/apps/site/app/routes.ts
index 48c1847..930a2e7 100644
--- a/apps/site/app/routes.ts
+++ b/apps/site/app/routes.ts
@@ -4,4 +4,5 @@ export default [
index("routes/home.tsx"),
route("hello", "routes/hello.tsx"),
route("cloud-hello", "routes/cloud-hello.tsx"),
+ route("*", "routes/localized-site.tsx"),
] satisfies RouteConfig;
diff --git a/apps/site/app/routes/cloud-hello.tsx b/apps/site/app/routes/cloud-hello.tsx
index d2251fd..fc50833 100644
--- a/apps/site/app/routes/cloud-hello.tsx
+++ b/apps/site/app/routes/cloud-hello.tsx
@@ -1,75 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-only
-import { Button } from "@arcforges/web-ui";
-import { useEffect, useRef, useState } from "react";
-import { checkServerConnection } from "../cloud-hello";
+import { useLoaderData } from "react-router";
+import { loadCloudHelloPage } from "../site-content.server";
+import { metadataForPage } from "../site-metadata";
+import { CloudHelloPageView } from "../site-pages";
-export function meta() {
- return [{ title: "Server connection — ArcForges" }];
+export function loader() {
+ return loadCloudHelloPage();
}
-export default function CloudHello() {
- const [ready, setReady] = useState(false);
- const [state, setState] = useState<"idle" | "pending" | "success" | "error">("idle");
- const [reply, setReply] = useState("");
- const active = useRef(null);
- useEffect(() => {
- setReady(true);
- return () => active.current?.abort();
- }, []);
-
- async function connect() {
- if (active.current) return;
- const controller = new AbortController();
- active.current = controller;
- setState("pending");
- try {
- const message = await checkServerConnection(window.location.origin, controller.signal);
- if (!controller.signal.aborted) {
- setReply(message);
- setState("success");
- }
- } catch {
- if (!controller.signal.aborted) setState("error");
- } finally {
- if (active.current === controller) active.current = null;
- }
- }
-
- const message = {
- idle: "No request sent yet.",
- pending: "Contacting the server…",
- success: reply,
- error: "The server is unavailable or returned an unexpected response. Try again later.",
- }[state];
+export function meta({
+ loaderData,
+}: {
+ loaderData: ReturnType | undefined;
+}) {
+ return loaderData ? metadataForPage(loaderData) : [];
+}
- return (
-
-
- ← Back to your hello
-
- Connection example
-
- A hello, from the server.
-
-
- Send a fixed “ArcForges” greeting to check the server connection. Your name from the local
- example stays in your browser. Nothing is sent until you choose to connect.
-
-
- {state === "pending" ? "Connecting…" : "Check connection"}
-
-
- Enable JavaScript to check the server connection.
-
-
-
Server response
-
{message}
-
-
- );
+export default function CloudHello() {
+ const page = useLoaderData();
+ return ;
}
diff --git a/apps/site/app/routes/hello.tsx b/apps/site/app/routes/hello.tsx
index 7dcdec1..45d9cf3 100644
--- a/apps/site/app/routes/hello.tsx
+++ b/apps/site/app/routes/hello.tsx
@@ -1,82 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
-import { Button } from "@arcforges/web-ui";
-import { type FormEvent, useEffect, useState } from "react";
-import { greet } from "../hello";
+import { useLoaderData } from "react-router";
+import { loadHelloPage } from "../site-content.server";
+import { metadataForPage } from "../site-metadata";
+import { HelloPageView } from "../site-pages";
-export function meta() {
- return [{ title: "Your hello — ArcForges" }];
+export function loader() {
+ return loadHelloPage();
+}
+
+export function meta({ loaderData }: { loaderData: ReturnType | undefined }) {
+ return loaderData ? metadataForPage(loaderData) : [];
}
export default function Hello() {
- const [name, setName] = useState("World");
- const [message, setMessage] = useState("Hello, World!");
- const [error, setError] = useState("");
- const [ready, setReady] = useState(false);
- useEffect(() => {
- setReady(true);
- }, []);
- function submit(event: FormEvent) {
- event.preventDefault();
- try {
- setMessage(greet(name));
- setError("");
- } catch {
- setError("Enter a name between 1 and 80 characters, without control characters.");
- }
- }
- return (
-
-
- ← Back home
-
-
-
- Hello example
-
-
- A hello, just for you.
-
-
- Start with your name. This example runs in your browser, and your name stays on this page.
-
-
-
-
- The example greeting is shown below. Enable JavaScript to personalize it.
-
-
-
-
Your greeting
-
{message}
-
-
- Check the server connection →
-
-
- );
+ const page = useLoaderData();
+ return ;
}
diff --git a/apps/site/app/routes/home.tsx b/apps/site/app/routes/home.tsx
index 65332e4..6c6191c 100644
--- a/apps/site/app/routes/home.tsx
+++ b/apps/site/app/routes/home.tsx
@@ -1,48 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
-import { Arrow } from "@arcforges/web-ui";
+import { useLoaderData } from "react-router";
+import { loadHomePage } from "../site-content.server";
+import { metadataForPage } from "../site-metadata";
+import { HomePageView } from "../site-pages";
-export function meta() {
- return [
- { title: "Hello, world. — ArcForges" },
- { name: "description", content: "A small, open-source beginning for ArcForges Web." },
- ];
+export function loader() {
+ return loadHomePage();
+}
+
+export function meta({ loaderData }: { loaderData: ReturnType | undefined }) {
+ return loaderData ? metadataForPage(loaderData) : [];
}
export default function Home() {
- return (
- <>
-
-
-
-
- ArcForges Web · Early preview
-
-
- Hello,
-
- world.
-
-
- Every good thing starts somewhere.
-
- Welcome to the first page of ArcForges Web.
-
-
- Make it your hello
-
-
-
-
- hello.
-
-
-
- A beginning you can explore.
-
- This small preview has one job: say hello. Try the example, look around the source, and
- follow along as ArcForges takes shape.
-
-
- >
- );
+ const page = useLoaderData();
+ return ;
}
diff --git a/apps/site/app/routes/localized-site.tsx b/apps/site/app/routes/localized-site.tsx
new file mode 100644
index 0000000..7928aa5
--- /dev/null
+++ b/apps/site/app/routes/localized-site.tsx
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { useLoaderData } from "react-router";
+import { metadataForPage } from "../site-metadata";
+import { loadLocalizedPage } from "../site-content.server";
+import { CloudHelloPageView, HelloPageView, HomePageView } from "../site-pages";
+
+export function loader({ params }: { params: Record }) {
+ return loadLocalizedPage(params);
+}
+
+export function meta({
+ loaderData,
+}: {
+ loaderData: Awaited> | undefined;
+}) {
+ return loaderData ? metadataForPage(loaderData) : [];
+}
+
+export default function LocalizedSitePage() {
+ const page = useLoaderData();
+ switch (page.kind) {
+ case "home":
+ return ;
+ case "hello":
+ return ;
+ case "cloud-hello":
+ return ;
+ case "document":
+ return (
+
+
+ ← Back home
+
+ Documentation · {page.version}
+ {page.title}
+ {page.description}
+ {page.body}
+
+ );
+ }
+}
diff --git a/apps/site/app/site-content.server.ts b/apps/site/app/site-content.server.ts
new file mode 100644
index 0000000..166b989
--- /dev/null
+++ b/apps/site/app/site-content.server.ts
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import {
+ normalizePublicPath,
+ pageById,
+ pageByLocalizedPath,
+ publicSiteManifest,
+ type PublicSiteManifest,
+ type SitePage,
+} from "./site-manifest";
+
+export type SitePageData = SitePage & {
+ canonicalUrl: string;
+ alternateLanguages: readonly { locale: string; href: string }[];
+};
+
+type PageMetadata = Pick;
+
+function withMetadata(
+ page: T,
+ manifest: PublicSiteManifest = publicSiteManifest,
+): T & PageMetadata {
+ return {
+ ...page,
+ canonicalUrl: new URL(page.localizedPath, manifest.origin).href,
+ alternateLanguages: manifest.pages
+ .filter((candidate) => candidate.id === page.id)
+ .sort((left, right) => (left.locale < right.locale ? -1 : left.locale > right.locale ? 1 : 0))
+ .map((candidate) => ({
+ locale: candidate.locale,
+ href: new URL(candidate.localizedPath, manifest.origin).href,
+ })),
+ };
+}
+
+export function loadHomePage() {
+ return withMetadata(pageById("home"));
+}
+
+export function loadHelloPage() {
+ return withMetadata(pageById("hello"));
+}
+
+export function loadCloudHelloPage() {
+ return withMetadata(pageById("cloud-hello"));
+}
+
+export function loadLocalizedPage(
+ params: Record,
+ manifest: PublicSiteManifest = publicSiteManifest,
+): SitePageData {
+ const locale = params.locale;
+ if (locale && !/^[a-z]{2,3}(?:-[A-Z]{2})?$/u.test(locale)) {
+ throw new Response("Page not found", { status: 404 });
+ }
+ const wildcard = params["*"] ?? "";
+ const pathSegments = wildcard.split("/").filter(Boolean);
+ const inferredLocale =
+ locale ?? manifest.locales.find((candidate) => candidate.pathPrefix === pathSegments[0])?.code;
+ let path: string;
+ try {
+ const defaultPath = wildcard.startsWith("docs/") ? `/${wildcard}` : `/docs/${wildcard}`;
+ path = normalizePublicPath(
+ locale ? `/${locale}/${wildcard}` : inferredLocale ? `/${wildcard}` : defaultPath,
+ );
+ } catch {
+ throw new Response("Page not found", { status: 404 });
+ }
+ const page = inferredLocale
+ ? pageByLocalizedPath(path, manifest)
+ : manifest.pages.find((candidate) => candidate.defaultPath === path);
+ if (!page || (inferredLocale && page.locale !== inferredLocale)) {
+ throw new Response("Page not found", { status: 404 });
+ }
+ return withMetadata(page, manifest);
+}
diff --git a/apps/site/app/site-inputs.ts b/apps/site/app/site-inputs.ts
new file mode 100644
index 0000000..84e6afb
--- /dev/null
+++ b/apps/site/app/site-inputs.ts
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+export const inputs = {
+ schemaVersion: 1,
+ origin: "https://arcforges.com",
+ defaultLocale: "en",
+ locales: [{ code: "en", label: "English", pathPrefix: "en" }],
+ pages: [
+ {
+ id: "home",
+ kind: "home",
+ locale: "en",
+ defaultPath: "/",
+ localizedPath: "/en/",
+ title: "Hello, world. — ArcForges",
+ description: "A small, open-source beginning for ArcForges Web.",
+ headingFirst: "Hello,",
+ headingSecond: "world.",
+ eyebrow: "ArcForges Web · Early preview",
+ lead: ["Every good thing starts somewhere.", "Welcome to the first page of ArcForges Web."],
+ primaryLink: { href: "/hello/", label: "Make it your hello" },
+ introTitle: "A beginning you can explore.",
+ introBody:
+ "This small preview has one job: say hello. Try the example, look around the source, and follow along as ArcForges takes shape.",
+ },
+ {
+ id: "hello",
+ kind: "hello",
+ locale: "en",
+ defaultPath: "/hello/",
+ localizedPath: "/en/hello/",
+ title: "Your hello — ArcForges",
+ description: "A local greeting example that runs in your browser.",
+ },
+ {
+ id: "cloud-hello",
+ kind: "cloud-hello",
+ locale: "en",
+ defaultPath: "/cloud-hello/",
+ localizedPath: "/en/cloud-hello/",
+ title: "Server connection — ArcForges",
+ description: "An optional example that checks the ArcForges server connection.",
+ },
+ ],
+ documentationVersions: [],
+ redirects: [
+ {
+ source: "/account",
+ destination: "https://account.arcforges.com/",
+ status: 308,
+ },
+ ],
+} as const;
diff --git a/apps/site/app/site-manifest.ts b/apps/site/app/site-manifest.ts
new file mode 100644
index 0000000..75b1aa5
--- /dev/null
+++ b/apps/site/app/site-manifest.ts
@@ -0,0 +1,314 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { inputs } from "./site-inputs.ts";
+
+export interface SiteLocale {
+ code: string;
+ label: string;
+ pathPrefix: string;
+}
+
+interface SitePageBase {
+ id: string;
+ locale: string;
+ defaultPath?: string;
+ localizedPath: string;
+ title: string;
+ description: string;
+}
+
+export interface HomePage extends SitePageBase {
+ kind: "home";
+ eyebrow: string;
+ headingFirst: string;
+ headingSecond: string;
+ lead: string[];
+ primaryLink: { href: string; label: string };
+ introTitle: string;
+ introBody: string;
+}
+
+export interface ExamplePage extends SitePageBase {
+ kind: "hello" | "cloud-hello";
+}
+
+export interface DocumentPage extends SitePageBase {
+ kind: "document";
+ body: string;
+ version: string;
+}
+
+export type SitePage = HomePage | ExamplePage | DocumentPage;
+
+export interface SiteRedirect {
+ source: string;
+ destination: string;
+ status: 301 | 302 | 303 | 307 | 308;
+}
+
+export interface PublicSiteManifest {
+ schemaVersion: 1;
+ origin: string;
+ defaultLocale: string;
+ locales: readonly SiteLocale[];
+ pages: readonly SitePage[];
+ documentationVersions: readonly string[];
+ redirects: readonly SiteRedirect[];
+}
+
+export const publicSiteManifest = inputs as unknown as PublicSiteManifest;
+
+function ordinal(left: string, right: string): number {
+ return left < right ? -1 : left > right ? 1 : 0;
+}
+
+export function normalizePublicPath(path: string): string {
+ if (!path.startsWith("/") || path.startsWith("//")) {
+ throw new Error(`Public paths must be root-relative: ${path}`);
+ }
+ const hasControlCharacter = Array.from(path).some((character) => {
+ const codePoint = character.codePointAt(0) ?? 0;
+ return codePoint < 0x20 || codePoint === 0x7f;
+ });
+ if (/[?#%]/u.test(path) || path.includes("\\") || path.includes("//") || hasControlCharacter) {
+ throw new Error(
+ `Public paths contain a query, fragment, encoding, repeated slash or control: ${path}`,
+ );
+ }
+ const segments = path.split("/").filter(Boolean);
+ if (
+ segments.some(
+ (segment) =>
+ segment === "." ||
+ segment === ".." ||
+ !/^[A-Za-z0-9._~-]+$/u.test(segment) ||
+ segment.endsWith("."),
+ )
+ ) {
+ throw new Error(`Public paths contain an unsafe segment: ${path}`);
+ }
+ const normalized = `/${segments.join("/")}`;
+ return normalized === "/" ? normalized : `${normalized}/`;
+}
+
+export function validatePublicSiteManifest(
+ manifest: PublicSiteManifest = publicSiteManifest,
+): void {
+ if (manifest.schemaVersion !== 1) throw new Error("Unsupported public site manifest version");
+ if (manifest.origin !== "https://arcforges.com") throw new Error("Unexpected public site origin");
+
+ const localeCodes = manifest.locales.map((locale) => locale.code);
+ const localePrefixes = manifest.locales.map((locale) => locale.pathPrefix);
+ if (new Set(localeCodes).size !== localeCodes.length) throw new Error("Duplicate site locale");
+ if (new Set(localePrefixes).size !== localePrefixes.length) {
+ throw new Error("Duplicate site locale path prefix");
+ }
+ if (!localeCodes.includes(manifest.defaultLocale)) throw new Error("Unknown default site locale");
+ for (const locale of manifest.locales) {
+ if (!/^[a-z]{2,3}(?:-[A-Z]{2})?$/u.test(locale.code)) {
+ throw new Error(`Invalid locale code: ${locale.code}`);
+ }
+ if (!/^[a-z0-9-]+$/u.test(locale.pathPrefix)) {
+ throw new Error(`Invalid locale path prefix: ${locale.pathPrefix}`);
+ }
+ }
+
+ const pageLocales = new Set();
+ const paths = new Set();
+ for (const page of manifest.pages) {
+ const identity = `${page.id}\u0000${page.locale}`;
+ if (!/^[a-z0-9-]+$/u.test(page.id) || pageLocales.has(identity)) {
+ throw new Error(`Invalid or duplicate public page/locale: ${page.id}/${page.locale}`);
+ }
+ pageLocales.add(identity);
+ if (!localeCodes.includes(page.locale)) throw new Error(`Unknown page locale: ${page.locale}`);
+ const locale = manifest.locales.find((candidate) => candidate.code === page.locale);
+ const localizedPath = normalizePublicPath(page.localizedPath);
+ if (
+ !localizedPath.startsWith(`/${locale?.pathPrefix}/`) &&
+ localizedPath !== `/${locale?.pathPrefix}`
+ ) {
+ throw new Error(`Localized path does not match locale prefix: ${page.localizedPath}`);
+ }
+ for (const candidate of [page.defaultPath, page.localizedPath].filter(
+ (path): path is string => path !== undefined,
+ )) {
+ const path = normalizePublicPath(candidate);
+ if (paths.has(path)) throw new Error(`Duplicate public route path: ${path}`);
+ paths.add(path);
+ const segments = path.split("/").filter(Boolean);
+ const routeRoot = localeCodes.includes(segments[0] ?? "") ? segments[1] : segments[0];
+ if (["account", "chat", "ops", "api"].includes(routeRoot ?? "")) {
+ throw new Error(`Private application path cannot be a public site route: ${path}`);
+ }
+ }
+ if (!page.title.trim() || !page.description.trim()) {
+ throw new Error(`Public page metadata is required: ${page.id}`);
+ }
+ if (page.locale !== manifest.defaultLocale && page.defaultPath !== undefined) {
+ throw new Error(`Only the default locale may own a default path: ${page.id}/${page.locale}`);
+ }
+ if (page.locale === manifest.defaultLocale && page.defaultPath === undefined) {
+ throw new Error(`Default locale route is missing its compatibility path: ${page.id}`);
+ }
+ if (page.kind === "document" && !manifest.documentationVersions.includes(page.version)) {
+ throw new Error(`Document page uses an unregistered version: ${page.version}`);
+ }
+ }
+
+ for (const pageId of new Set(manifest.pages.map((page) => page.id))) {
+ if (!pageLocales.has(`${pageId}\u0000${manifest.defaultLocale}`)) {
+ throw new Error(`Public page is missing its default locale: ${pageId}`);
+ }
+ }
+
+ for (const page of manifest.pages) {
+ if (page.kind !== "home") continue;
+ const target = normalizePublicPath(page.primaryLink.href);
+ if (!paths.has(target)) {
+ throw new Error(`Home page primary link must target a known public route: ${target}`);
+ }
+ if (!page.primaryLink.label.trim()) {
+ throw new Error(`Home page primary link label is required: ${page.id}/${page.locale}`);
+ }
+ }
+
+ const redirectSources = new Set();
+ const redirectTargets = new Map();
+ for (const redirect of manifest.redirects) {
+ const source = normalizePublicPath(redirect.source);
+ if (redirectSources.has(source)) throw new Error(`Duplicate redirect source: ${source}`);
+ if (paths.has(source))
+ throw new Error(`Redirect source collides with a public route: ${source}`);
+ redirectSources.add(source);
+ const target = new URL(redirect.destination, manifest.origin);
+ if (target.protocol !== "https:")
+ throw new Error(`Redirect must use HTTPS: ${redirect.destination}`);
+ if (target.username || target.password || target.search || target.hash) {
+ throw new Error("Redirect destination cannot contain credentials, a query or a fragment");
+ }
+ if (target.origin !== manifest.origin && target.origin !== "https://account.arcforges.com") {
+ throw new Error(`Redirect uses an unapproved public origin: ${target.origin}`);
+ }
+ if (target.origin === manifest.origin) {
+ redirectTargets.set(source, normalizePublicPath(target.pathname));
+ }
+ if (![301, 302, 303, 307, 308].includes(redirect.status)) {
+ throw new Error(`Unsupported redirect status: ${redirect.status}`);
+ }
+ }
+
+ for (const [source, firstTarget] of redirectTargets) {
+ const visited = new Set();
+ let target = firstTarget;
+ while (redirectTargets.has(target)) {
+ if (visited.has(target) || target === source) throw new Error(`Redirect loop: ${source}`);
+ visited.add(target);
+ const next = redirectTargets.get(target);
+ if (next === undefined) break;
+ target = next;
+ }
+ if (!paths.has(target)) {
+ throw new Error(
+ `Redirect must resolve to a known public route or approved external origin: ${target}`,
+ );
+ }
+ }
+
+ const versions = manifest.documentationVersions;
+ if (new Set(versions).size !== versions.length)
+ throw new Error("Duplicate documentation version");
+ if (versions.some((version) => !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(version))) {
+ throw new Error("Invalid documentation version identifier");
+ }
+}
+
+export function allPrerenderPaths(manifest: PublicSiteManifest = publicSiteManifest): string[] {
+ validatePublicSiteManifest(manifest);
+ const paths = manifest.pages.flatMap((page) => [page.defaultPath, page.localizedPath]);
+ return [
+ ...new Set(paths.filter((path): path is string => path !== undefined).map(normalizePublicPath)),
+ ].sort(ordinal);
+}
+
+export function localeForPath(
+ pathname: string,
+ manifest: PublicSiteManifest = publicSiteManifest,
+): string {
+ const firstSegment = pathname.split("/").find((segment) => segment.length > 0);
+ return (
+ manifest.locales.find((locale) => locale.pathPrefix === firstSegment)?.code ??
+ manifest.defaultLocale
+ );
+}
+
+export function sitemapUrls(manifest: PublicSiteManifest = publicSiteManifest): string[] {
+ validatePublicSiteManifest(manifest);
+ return manifest.pages
+ .map((page) => new URL(normalizePublicPath(page.localizedPath), manifest.origin).href)
+ .sort(ordinal);
+}
+
+export function renderSitemapXml(manifest: PublicSiteManifest = publicSiteManifest): string {
+ const urls = sitemapUrls(manifest);
+ const entries = urls.map((url) => ` ${escapeXml(url)} `).join("\n");
+ return `\n\n${entries}\n \n`;
+}
+
+export function renderRedirectRules(manifest: PublicSiteManifest = publicSiteManifest): string {
+ validatePublicSiteManifest(manifest);
+ return `${[...manifest.redirects]
+ .sort((left, right) => ordinal(left.source, right.source))
+ .map((redirect) => `${redirect.source} ${redirect.destination} ${redirect.status}`)
+ .join("\n")}\n`;
+}
+
+export function publicManifestDocument(manifest: PublicSiteManifest = publicSiteManifest) {
+ validatePublicSiteManifest(manifest);
+ return {
+ schemaVersion: manifest.schemaVersion,
+ origin: manifest.origin,
+ defaultLocale: manifest.defaultLocale,
+ locales: [...manifest.locales].sort((left, right) => ordinal(left.code, right.code)),
+ pages: [...manifest.pages]
+ .map((page) => ({
+ id: page.id,
+ locale: page.locale,
+ paths: [page.defaultPath, page.localizedPath]
+ .filter((path): path is string => path !== undefined)
+ .map(normalizePublicPath)
+ .sort(ordinal),
+ canonical: new URL(normalizePublicPath(page.localizedPath), manifest.origin).href,
+ title: page.title,
+ description: page.description,
+ }))
+ .sort((left, right) => ordinal(left.id, right.id) || ordinal(left.locale, right.locale)),
+ documentationVersions: [...manifest.documentationVersions].sort(ordinal),
+ redirects: [...manifest.redirects]
+ .sort((left, right) => ordinal(left.source, right.source))
+ .map((redirect) => ({ ...redirect })),
+ };
+}
+
+export function pageById(id: "home"): HomePage;
+export function pageById(id: "hello"): ExamplePage;
+export function pageById(id: "cloud-hello"): ExamplePage;
+export function pageById(id: string): SitePage;
+export function pageById(id: string): SitePage {
+ const page = publicSiteManifest.pages.find(
+ (candidate) => candidate.id === id && candidate.locale === publicSiteManifest.defaultLocale,
+ );
+ if (!page) throw new Error(`Missing public site page: ${id}`);
+ return page;
+}
+
+export function pageByLocalizedPath(
+ path: string,
+ manifest: PublicSiteManifest = publicSiteManifest,
+): SitePage | undefined {
+ const normalized = normalizePublicPath(path);
+ return manifest.pages.find((page) => normalizePublicPath(page.localizedPath) === normalized);
+}
+
+function escapeXml(value: string): string {
+ return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
+}
diff --git a/apps/site/app/site-metadata.ts b/apps/site/app/site-metadata.ts
new file mode 100644
index 0000000..20a72d3
--- /dev/null
+++ b/apps/site/app/site-metadata.ts
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import type { MetaDescriptor } from "react-router";
+
+export interface SitePageMetadata {
+ title: string;
+ description: string;
+ canonicalUrl: string;
+ alternateLanguages: readonly { locale: string; href: string }[];
+}
+
+export function metadataForPage(page: SitePageMetadata) {
+ return [
+ { title: page.title },
+ { name: "description", content: page.description },
+ { property: "og:title", content: page.title },
+ { property: "og:description", content: page.description },
+ { property: "og:type", content: "website" },
+ { property: "og:url", content: page.canonicalUrl },
+ { tagName: "link", rel: "canonical", href: page.canonicalUrl },
+ ...page.alternateLanguages.map((alternate) => ({
+ tagName: "link" as const,
+ rel: "alternate",
+ hrefLang: alternate.locale,
+ href: alternate.href,
+ })),
+ ] satisfies MetaDescriptor[];
+}
diff --git a/apps/site/app/site-pages.tsx b/apps/site/app/site-pages.tsx
new file mode 100644
index 0000000..9989304
--- /dev/null
+++ b/apps/site/app/site-pages.tsx
@@ -0,0 +1,177 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { Arrow, Button } from "@arcforges/web-ui";
+import { type FormEvent, useEffect, useRef, useState } from "react";
+import { checkServerConnection } from "./cloud-hello";
+import { greet } from "./hello";
+import type { ExamplePage, HomePage } from "./site-manifest";
+
+export function HomePageView({ page }: { page: HomePage }) {
+ return (
+ <>
+
+
+
+
+ {page.eyebrow}
+
+
+ {page.headingFirst}
+
+ {page.headingSecond}
+
+
+ {page.lead.map((line, index) => (
+
+ {index > 0 && }
+ {line}
+
+ ))}
+
+
+ {page.primaryLink.label}
+
+
+
+
+ hello.
+
+
+
+ {page.introTitle}
+ {page.introBody}
+
+ >
+ );
+}
+
+export function HelloPageView({ page }: { page: ExamplePage }) {
+ const [name, setName] = useState("World");
+ const [message, setMessage] = useState("Hello, World!");
+ const [error, setError] = useState("");
+ const [ready, setReady] = useState(false);
+ useEffect(() => {
+ setReady(true);
+ }, []);
+ function submit(event: FormEvent) {
+ event.preventDefault();
+ try {
+ setMessage(greet(name));
+ setError("");
+ } catch {
+ setError("Enter a name between 1 and 80 characters, without control characters.");
+ }
+ }
+ return (
+
+
+ ← Back home
+
+
+
+ Hello example
+
+ {page.title}
+ {page.description}
+
+
+
+ The example greeting is shown below. Enable JavaScript to personalize it.
+
+
+
+
Your greeting
+
{message}
+
+
+ Check the server connection →
+
+
+ );
+}
+
+export function CloudHelloPageView({ page }: { page: ExamplePage }) {
+ const [ready, setReady] = useState(false);
+ const [state, setState] = useState<"idle" | "pending" | "success" | "error">("idle");
+ const [reply, setReply] = useState("");
+ const active = useRef(null);
+ useEffect(() => {
+ setReady(true);
+ return () => active.current?.abort();
+ }, []);
+
+ async function connect() {
+ if (active.current) return;
+ const controller = new AbortController();
+ active.current = controller;
+ setState("pending");
+ try {
+ const message = await checkServerConnection(window.location.origin, controller.signal);
+ if (!controller.signal.aborted) {
+ setReply(message);
+ setState("success");
+ }
+ } catch {
+ if (!controller.signal.aborted) setState("error");
+ } finally {
+ if (active.current === controller) active.current = null;
+ }
+ }
+
+ const message = {
+ idle: "No request sent yet.",
+ pending: "Contacting the server…",
+ success: reply,
+ error: "The server is unavailable or returned an unexpected response. Try again later.",
+ }[state];
+
+ return (
+
+
+ ← Back to your hello
+
+ Connection example
+ {page.title}
+ {page.description}
+
+ {state === "pending" ? "Connecting…" : "Check connection"}
+
+
+ Enable JavaScript to check the server connection.
+
+
+
Server response
+
{message}
+
+
+ );
+}
diff --git a/apps/site/package.json b/apps/site/package.json
index 4dbb60e..b40c05e 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -7,6 +7,7 @@
"scripts": {
"dev": "react-router dev --host 127.0.0.1 --port 5173 --strictPort",
"build": "react-router build",
+ "postbuild": "vitest run --config vitest.config.ts",
"typecheck": "react-router typegen && tsc -p tsconfig.json"
},
"dependencies": {
diff --git a/apps/site/react-router.config.ts b/apps/site/react-router.config.ts
index 30403bd..e8a4cb8 100644
--- a/apps/site/react-router.config.ts
+++ b/apps/site/react-router.config.ts
@@ -1,4 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { Config } from "@react-router/dev/config";
+import { allPrerenderPaths } from "./app/site-manifest";
-export default { ssr: false, prerender: ["/", "/hello", "/cloud-hello"] } satisfies Config;
+export default { ssr: false, prerender: allPrerenderPaths() } satisfies Config;
diff --git a/apps/site/tests/site-manifest.test.ts b/apps/site/tests/site-manifest.test.ts
new file mode 100644
index 0000000..45d49fd
--- /dev/null
+++ b/apps/site/tests/site-manifest.test.ts
@@ -0,0 +1,312 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, test } from "vitest";
+import {
+ allPrerenderPaths,
+ localeForPath,
+ pageById,
+ publicManifestDocument,
+ publicSiteManifest,
+ renderRedirectRules,
+ renderSitemapXml,
+ sitemapUrls,
+ validatePublicSiteManifest,
+ type DocumentPage,
+ type ExamplePage,
+ type HomePage,
+ type PublicSiteManifest,
+ type SitePage,
+} from "../app/site-manifest";
+import { loadLocalizedPage } from "../app/site-content.server";
+import { metadataForPage } from "../app/site-metadata";
+import routeConfig from "../app/routes";
+
+const siteRoot = fileURLToPath(new URL("..", import.meta.url));
+const clientRoot = join(siteRoot, "build", "client");
+
+function testOnlyVersionedManifest(): PublicSiteManifest {
+ const home: HomePage = {
+ id: "home",
+ kind: "home",
+ locale: "fr",
+ localizedPath: "/fr/",
+ title: "Bonjour, monde. — ArcForges",
+ description: "Fixture only: translated public home page.",
+ eyebrow: "ArcForges Web · Test fixture",
+ headingFirst: "Bonjour,",
+ headingSecond: "monde.",
+ lead: ["Fixture locale content."],
+ primaryLink: { href: "/fr/hello/", label: "Test link" },
+ introTitle: "Fixture locale",
+ introBody: "This translation exists only in a WEB.01 unit fixture.",
+ };
+ const documents: DocumentPage[] = ["v1", "v2"].map((version) => ({
+ id: `docs-arcscope-${version}`,
+ kind: "document",
+ locale: "en",
+ defaultPath: `/docs/arcscope/${version}/`,
+ localizedPath: `/en/docs/arcscope/${version}/`,
+ title: `ArcScope docs ${version}`,
+ description: `Fixture-only ArcScope documentation version ${version}.`,
+ body: `Fixture body for version ${version}.`,
+ version,
+ }));
+ const frenchHello: ExamplePage = {
+ id: "hello",
+ kind: "hello",
+ locale: "fr",
+ localizedPath: "/fr/hello/",
+ title: "Bonjour — ArcForges",
+ description: "Fixture-only localized hello page.",
+ };
+ const base = publicSiteManifest as PublicSiteManifest;
+ return {
+ ...base,
+ locales: [...base.locales, { code: "fr", label: "Français", pathPrefix: "fr" }],
+ pages: [...base.pages, home, frenchHello, ...documents] as SitePage[],
+ documentationVersions: ["v1", "v2"],
+ redirects: [
+ ...base.redirects,
+ { source: "/old-docs", destination: "/en/docs/arcscope/v2/", status: 308 },
+ ],
+ };
+}
+
+describe("public static-site inventory", () => {
+ test("registers each route module once so route identifiers are unique", () => {
+ const files = routeConfig.flatMap((route) => ("file" in route ? [route.file] : []));
+ expect(new Set(files).size).toBe(files.length);
+ });
+
+ test("pre-renders every default and locale-scoped public route in stable order", () => {
+ expect(allPrerenderPaths()).toEqual([
+ "/",
+ "/cloud-hello/",
+ "/en/",
+ "/en/cloud-hello/",
+ "/en/hello/",
+ "/hello/",
+ ]);
+ expect(allPrerenderPaths()).toEqual([...allPrerenderPaths()].sort());
+ });
+
+ test("publishes no private Account, Chat, Operations, API or runtime-config route", () => {
+ const document = publicManifestDocument();
+ const routePaths = document.pages.flatMap((page) => page.paths);
+ expect(routePaths.some((path) => /^\/(?:account|chat|ops|api)(?:\/|$)/u.test(path))).toBe(
+ false,
+ );
+ expect(JSON.stringify(document)).not.toMatch(
+ /(?:clientSecret|apiKey|privatePrice|accountRoute|chatRoute)/iu,
+ );
+ expect(publicSiteManifest.redirects).toContainEqual({
+ source: "/account",
+ destination: "https://account.arcforges.com/",
+ status: 308,
+ });
+ });
+
+ test("generates deterministic canonical metadata, language alternates, sitemap and redirects", () => {
+ const home = pageById("home");
+ const homeMetadata = {
+ ...home,
+ canonicalUrl: "https://arcforges.com/en/",
+ alternateLanguages: [{ locale: "en", href: "https://arcforges.com/en/" }],
+ };
+ expect(metadataForPage(homeMetadata)).toContainEqual({ title: "Hello, world. — ArcForges" });
+ expect(metadataForPage(homeMetadata)).toContainEqual({
+ tagName: "link",
+ rel: "canonical",
+ href: "https://arcforges.com/en/",
+ });
+ expect(metadataForPage(homeMetadata)).toContainEqual({
+ tagName: "link",
+ rel: "alternate",
+ hrefLang: "en",
+ href: "https://arcforges.com/en/",
+ });
+ expect(renderSitemapXml()).toContain("https://arcforges.com/en/");
+ expect(renderSitemapXml()).not.toContain("/account");
+ expect(renderRedirectRules()).toBe("/account https://account.arcforges.com/ 308\n");
+ });
+
+ test("a named test-only fixture covers multiple locales and documentation versions", () => {
+ const fixture = testOnlyVersionedManifest();
+ validatePublicSiteManifest(fixture);
+ expect(allPrerenderPaths(fixture)).toContain("/fr/");
+ expect(allPrerenderPaths(fixture)).toContain("/fr/hello/");
+ expect(allPrerenderPaths(fixture)).toContain("/docs/arcscope/v1/");
+ expect(allPrerenderPaths(fixture)).toContain("/en/docs/arcscope/v2/");
+ expect(publicManifestDocument(fixture).documentationVersions).toEqual(["v1", "v2"]);
+ expect(sitemapUrls(fixture)).toContain("https://arcforges.com/fr/");
+ expect(sitemapUrls(fixture)).toContain("https://arcforges.com/en/docs/arcscope/v2/");
+ expect(renderRedirectRules(fixture)).toContain("/old-docs /en/docs/arcscope/v2/ 308");
+ expect(loadLocalizedPage({ locale: undefined, "*": "arcscope/v2/" }, fixture).kind).toBe(
+ "document",
+ );
+ expect(loadLocalizedPage({ locale: undefined, "*": "docs/arcscope/v2/" }, fixture).kind).toBe(
+ "document",
+ );
+ expect(loadLocalizedPage({ locale: undefined, "*": "fr/hello/" }, fixture).locale).toBe("fr");
+ expect(loadLocalizedPage({ locale: "fr", "*": "hello/" }, fixture).locale).toBe("fr");
+ expect(localeForPath("/fr/hello/", fixture)).toBe("fr");
+ expect(localeForPath("/hello/", fixture)).toBe("en");
+ const shuffled = {
+ ...fixture,
+ locales: [...fixture.locales].reverse(),
+ pages: [...fixture.pages].reverse(),
+ redirects: [...fixture.redirects].reverse(),
+ };
+ expect(allPrerenderPaths(shuffled)).toEqual(allPrerenderPaths(fixture));
+ expect(publicManifestDocument(shuffled)).toEqual(publicManifestDocument(fixture));
+ expect(renderSitemapXml(shuffled)).toBe(renderSitemapXml(fixture));
+ expect(renderRedirectRules(shuffled)).toBe(renderRedirectRules(fixture));
+
+ const homeAlternates = fixture.pages
+ .filter((page) => page.id === "home")
+ .map((page) => ({
+ locale: page.locale,
+ href: new URL(page.localizedPath, fixture.origin).href,
+ }));
+ const frenchHome = fixture.pages.find((page) => page.id === "home" && page.locale === "fr");
+ if (!frenchHome) throw new Error("The test-only locale fixture is missing its French home");
+ const frenchMetadata = metadataForPage({
+ title: frenchHome.title,
+ description: frenchHome.description,
+ canonicalUrl: new URL(frenchHome.localizedPath, fixture.origin).href,
+ alternateLanguages: homeAlternates,
+ });
+ expect(frenchMetadata).toContainEqual({
+ tagName: "link",
+ rel: "canonical",
+ href: "https://arcforges.com/fr/",
+ });
+ expect(frenchMetadata).toContainEqual({
+ tagName: "link",
+ rel: "alternate",
+ hrefLang: "en",
+ href: "https://arcforges.com/en/",
+ });
+ expect(frenchMetadata).toContainEqual({
+ tagName: "link",
+ rel: "alternate",
+ hrefLang: "fr",
+ href: "https://arcforges.com/fr/",
+ });
+ });
+
+ test("rejects route collisions, private route additions and redirect traps", () => {
+ const base = publicSiteManifest as PublicSiteManifest;
+ const englishHome = pageById("home");
+ expect(() =>
+ validatePublicSiteManifest({
+ ...base,
+ pages: [...base.pages, { ...englishHome, localizedPath: "/en/" }],
+ }),
+ ).toThrow(/Invalid or duplicate public page\/locale/u);
+ expect(() =>
+ validatePublicSiteManifest({
+ ...base,
+ pages: [
+ ...base.pages,
+ {
+ id: "private",
+ kind: "hello",
+ locale: "en",
+ localizedPath: "/en/account/",
+ title: "Private route",
+ description: "This private route must not enter the public site.",
+ },
+ ],
+ }),
+ ).toThrow(/Private application path/u);
+ expect(() =>
+ validatePublicSiteManifest({
+ ...base,
+ redirects: [{ source: "/loop/", destination: "/loop/", status: 308 }],
+ }),
+ ).toThrow(/Redirect loop/u);
+ expect(() =>
+ validatePublicSiteManifest({
+ ...base,
+ redirects: [
+ { source: "/old-a/", destination: "/old-b/", status: 308 },
+ { source: "/old-b/", destination: "/old-a/", status: 308 },
+ ],
+ }),
+ ).toThrow(/Redirect loop/u);
+ expect(() =>
+ validatePublicSiteManifest({
+ ...base,
+ redirects: [{ source: "/hello/", destination: "/en/hello/", status: 308 }],
+ }),
+ ).toThrow(/collides with a public route/u);
+ expect(() =>
+ validatePublicSiteManifest({
+ ...base,
+ pages: base.pages.map((page) =>
+ page.id === "home"
+ ? { ...page, primaryLink: { href: "/account/", label: "Account" } }
+ : page,
+ ),
+ }),
+ ).toThrow(/known public route/u);
+ expect(() =>
+ validatePublicSiteManifest({
+ ...base,
+ redirects: [
+ { source: "/old/", destination: "https://arcforges.com/account/", status: 308 },
+ ],
+ }),
+ ).toThrow(/known public route/u);
+ });
+
+ test("a one-page metadata edit stays local to that page's public inventory", () => {
+ const original = publicSiteManifest as PublicSiteManifest;
+ const changed: PublicSiteManifest = {
+ ...original,
+ pages: original.pages.map((page) =>
+ page.id === "home" ? { ...page, description: `${page.description} Revised.` } : page,
+ ),
+ };
+ const before = publicManifestDocument(original);
+ const after = publicManifestDocument(changed);
+ expect(after.pages.find((page) => page.id === "home")?.description).toBe(
+ `${before.pages.find((page) => page.id === "home")?.description} Revised.`,
+ );
+ expect(after.pages.filter((page) => page.id !== "home")).toEqual(
+ before.pages.filter((page) => page.id !== "home"),
+ );
+ expect(renderSitemapXml(changed)).toBe(renderSitemapXml(original));
+ expect(renderRedirectRules(changed)).toBe(renderRedirectRules(original));
+ });
+
+ test("the built HTML is complete before scripts and generated public artifacts match the inventory", async () => {
+ const home = await readFile(join(clientRoot, "index.html"), "utf8");
+ const localizedHome = await readFile(join(clientRoot, "en", "index.html"), "utf8");
+ const notFound = await readFile(join(clientRoot, "404.html"), "utf8");
+ const emittedManifest = await readFile(join(clientRoot, "site-manifest.json"), "utf8");
+ const emittedSitemap = await readFile(join(clientRoot, "sitemap.xml"), "utf8");
+ const emittedRedirects = await readFile(join(clientRoot, "_redirects"), "utf8");
+
+ for (const html of [home, localizedHome]) {
+ expect(html).toContain("A beginning you can explore.");
+ expect(html).toContain("Make it your hello");
+ expect(html).toContain('rel="canonical"');
+ expect(html).toMatch(/hreflang="en"/iu);
+ expect(html).toContain('name="robots" content="noindex, nofollow"');
+ }
+ const helloAnchor = 'Make it your hello';
+ const helloAnchorIndex = home.indexOf(helloAnchor);
+ const firstScriptIndex = home.indexOf("