diff --git a/frontend/docs/.gitignore b/frontend/docs/.gitignore index 4a985a8c0..baefc7903 100644 --- a/frontend/docs/.gitignore +++ b/frontend/docs/.gitignore @@ -9,3 +9,4 @@ dist/ /public/llms-full.txt /test-results/ /playwright-report/ +/dist-ssr/ diff --git a/frontend/docs/e2e/docs.spec.ts b/frontend/docs/e2e/docs.spec.ts index 077bd293d..6afa8ed81 100644 --- a/frontend/docs/e2e/docs.spec.ts +++ b/frontend/docs/e2e/docs.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test" -import { aliases, headings, slug } from "../src/navigation" +import { aliases, documentTitle, headings, slug } from "../src/navigation" import { readFileSync } from "node:fs" const config = JSON.parse(readFileSync(new URL("../src/content/openscience/docs.json", import.meta.url), "utf8")) as { @@ -13,9 +13,9 @@ test("all pages render with working section links and page metadata", async ({ p for (const name of pages) { const source = readFileSync(new URL("../src/content/openscience/" + name + ".mdx", import.meta.url), "utf8") const title = source.match(/^title: "(.+)"$/m)![1] - await page.goto("#/openscience/" + name) + await page.goto(name === "index" ? "./" : name) await expect(page.getByRole("heading", { level: 1 })).toHaveText(title) - await expect(page).toHaveTitle(title + " · OpenScience Docs") + await expect(page).toHaveTitle(documentTitle(title, name)) for (const heading of headings(source, 3)) await expect(page.locator('[id="' + slug(heading) + '"]')).toHaveCount(1) const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth) expect(overflow, name + " must fit the viewport").toBe(false) @@ -24,7 +24,7 @@ test("all pages render with working section links and page metadata", async ({ p }) test("section navigation, old links, and back navigation keep the right page", async ({ page }) => { - await page.goto("#/openscience/sessions") + await page.goto("sessions") await page.getByRole("complementary", { name: "on this page" }).getByRole("link", { name: "Exit codes" }).click() await expect(page).toHaveURL(/sessions#exit-codes$/) await expect(page.getByRole("heading", { level: 1 })).toHaveText("Sessions and terminal runs") @@ -33,15 +33,15 @@ test("section navigation, old links, and back navigation keep the right page", a await expect(page).toHaveURL(/sessions#exit-codes$/) for (const [old, current] of Object.entries(aliases)) { await page.goto("#/openscience/" + old) - await expect(page).toHaveURL(new RegExp("/openscience/" + current + "$")) + await expect(page).toHaveURL(new RegExp("/docs/" + current + "$")) await expect(page.getByRole("heading", { level: 1 })).not.toHaveText("Page not found") } - await page.goto("#/openscience/missing") + await page.goto("missing") await expect(page.getByRole("heading", { level: 1 })).toHaveText("Page not found") }) test("keyboard search selects a result and escape dismisses it", async ({ page }) => { - await page.goto("#/openscience/index") + await page.goto("./") const search = page.getByRole("combobox", { name: "Search documentation" }) await search.fill("pricing") await search.press("Enter") @@ -54,7 +54,7 @@ test("keyboard search selects a result and escape dismisses it", async ({ page } test("mobile navigation, search, and wide tables remain usable", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) - await page.goto("#/openscience/index") + await page.goto("./") await expect(page.getByRole("combobox", { name: "Search documentation" })).toBeVisible() const menu = page.getByRole("button", { name: "Browse documentation" }) await expect(menu).toHaveAttribute("aria-expanded", "false") @@ -68,7 +68,7 @@ test("mobile navigation, search, and wide tables remain usable", async ({ page } test("code copy reports success and exports are served", async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]) - await page.goto("#/openscience/quickstart") + await page.goto("quickstart") await page.getByRole("button", { name: "Copy code", exact: true }).first().click() await expect(page.getByRole("button", { name: "Copy code", exact: true }).first()).toContainText("Copied") expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("npm install -g @synsci/openscience") @@ -78,12 +78,12 @@ test("code copy reports success and exports are served", async ({ page, context const full = await page.request.get("llms-full.txt") expect(full.ok()).toBe(true) expect(await full.text()).toContain("## Ace terms") - await page.goto("#/openscience/index") + await page.goto("./") await page.screenshot({ path: "test-results/docs-desktop.png", fullPage: false }) }) test("tools and skills tabs expose complete catalogs with usable deep links", async ({ page }) => { - await page.goto("#/openscience/index") + await page.goto("./") const sections = page.getByRole("navigation", { name: "documentation sections" }) await expect(sections.getByRole("link")).toHaveCount(5) await sections.getByRole("link", { name: "Explore tools", exact: true }).click() diff --git a/frontend/docs/index.html b/frontend/docs/index.html index 11bb821b4..65f37821d 100644 --- a/frontend/docs/index.html +++ b/frontend/docs/index.html @@ -9,6 +9,34 @@ name="description" content="Documentation for OpenScience, the open-source AI workbench for scientific research." /> + + + + + + + + + + + + + + diff --git a/frontend/docs/package.json b/frontend/docs/package.json index 44663984e..9cac37bb3 100644 --- a/frontend/docs/package.json +++ b/frontend/docs/package.json @@ -6,7 +6,7 @@ "license": "Apache-2.0", "scripts": { "dev": "vite", - "build": "bun run export && vite build", + "build": "bun run export && vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && bun script/prerender.ts && rm -rf dist-ssr", "preview": "vite preview", "typecheck": "tsc --noEmit", "catalog": "bun --no-env-file script/catalog.ts", diff --git a/frontend/docs/script/export.ts b/frontend/docs/script/export.ts index d7cf36ce4..f1ce73340 100644 --- a/frontend/docs/script/export.ts +++ b/frontend/docs/script/export.ts @@ -1,5 +1,6 @@ import path from "node:path" import config from "../src/content/openscience/docs.json" +import { pageUrl } from "../src/navigation" const output = path.resolve(import.meta.dir, "../public") const directory = path.resolve(import.meta.dir, "../src/content/openscience") @@ -8,10 +9,10 @@ const index = [ "", "> Guides for installation, model access, Ace pricing, scientific research, and customization.", "", - "Full text: https://openscience.sh/docs/llms-full.txt", + "Full text: https://www.openscience.sh/docs/llms-full.txt", "", ] -const full = ["# OpenScience documentation", "", "Source: https://openscience.sh/docs/", ""] +const full = ["# OpenScience documentation", "", "Source: https://www.openscience.sh/docs", ""] for (const tab of config.navigation.tabs) { for (const group of tab.groups) { index.push("## " + group.group, "") @@ -19,12 +20,12 @@ for (const tab of config.navigation.tabs) { const raw = await Bun.file(path.join(directory, name + ".mdx")).text() const title = raw.match(/^title: "(.+)"$/m)?.[1] ?? name const description = raw.match(/^description: "(.+)"$/m)?.[1] ?? "" - const url = "https://openscience.sh/docs/#/openscience/" + name + const url = pageUrl(name) const body = raw .replace(/^---\n[\s\S]*?\n---\n?/, "") .replace(/\s*([\s\S]*?)\s*<\/Card>/g, "[$1]($2): $3") .replace(/<\/?(?:Columns|CardGroup)\b[^>]*>/g, "") - .replace(/\]\(\/openscience\/([^)]*)\)/g, "](https://openscience.sh/docs/#/openscience/$1)") + .replace(/\]\(\/openscience\/([^)]*)\)/g, "](https://www.openscience.sh/docs/$1)") index.push("- [" + title + "](" + url + "): " + description) full.push("# " + title, "", description, "", "URL: " + url, "", body.trim(), "") } diff --git a/frontend/docs/script/prerender.ts b/frontend/docs/script/prerender.ts new file mode 100644 index 000000000..bddd277f9 --- /dev/null +++ b/frontend/docs/script/prerender.ts @@ -0,0 +1,123 @@ +// Runs after the client and SSR builds. Writes every page to dist/.html +// with its own title, description, canonical URL and JSON-LD, plus 404.html +// for unknown paths and sitemap.xml, so each page can be indexed on its own. +import path from "node:path" +import { BASE, SITE, documentTitle, pageUrl } from "../src/navigation" + +type Page = { path: string; title: string; description: string } +type Group = { group: string; pages: Array } + +const dist = path.resolve(import.meta.dir, "../dist") +const ssr = await import(path.resolve(import.meta.dir, "../dist-ssr/entry-server.js")) +const render: (path: string) => string = ssr.render +const pages: Record = ssr.DOC_PAGES +const config: { navigation: { tabs: { groups: Group[] }[] } } = ssr.DOC_CONFIG + +const flatten = (items: Array): string[] => + items.flatMap((item) => (typeof item === "string" ? [item] : flatten(item.pages))) +const order = config.navigation.tabs.flatMap((tab) => tab.groups.flatMap((group) => flatten(group.pages))) + +const esc = (value: string) => + value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">") + +// Attributes may be split across lines in index.html, hence \s+. Replacements go +// through functions so `$` in a value isn't read as a group reference. +const TAGS: Array<[RegExp, "title" | "description" | "url"]> = [ + [/()[^<]*(<\/title>)/, "title"], + [/(<meta\s+name="description"\s+content=")[^"]*(")/, "description"], + [/(<link\s+rel="canonical"\s+href=")[^"]*(")/, "url"], + [/(<meta\s+property="og:url"\s+content=")[^"]*(")/, "url"], + [/(<meta\s+property="og:title"\s+content=")[^"]*(")/, "title"], + [/(<meta\s+property="og:description"\s+content=")[^"]*(")/, "description"], + [/(<meta\s+name="twitter:title"\s+content=")[^"]*(")/, "title"], + [/(<meta\s+name="twitter:description"\s+content=")[^"]*(")/, "description"], +] + +function withHead(html: string, values: Record<"title" | "description" | "url", string>): string { + return TAGS.reduce((out, [pattern, key]) => { + if (!pattern.test(out)) throw new Error(`prerender: ${pattern} not found in index.html`) + return out.replace(pattern, (_match, before: string, after: string) => before + esc(values[key]) + after) + }, html) +} + +function structuredData(page: Page, title: string): string { + const url = pageUrl(page.path) + const crumbs = [ + { name: "OpenScience", item: SITE + "/" }, + { name: "Docs", item: SITE + BASE }, + ...(page.path === "index" ? [] : [{ name: page.title, item: url }]), + ] + const data = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": page.path === "index" ? "CollectionPage" : "TechArticle", + "@id": url + "#page", + headline: page.title, + name: title, + description: page.description, + url, + inLanguage: "en", + isPartOf: { "@id": SITE + "/#website" }, + about: { "@type": "SoftwareApplication", name: "OpenScience", url: SITE + "/" }, + publisher: { "@type": "Organization", name: "Synthetic Sciences", url: "https://syntheticsciences.ai/" }, + breadcrumb: { "@id": url + "#breadcrumb" }, + }, + { + "@type": "BreadcrumbList", + "@id": url + "#breadcrumb", + itemListElement: crumbs.map((crumb, index) => ({ "@type": "ListItem", position: index + 1, ...crumb })), + }, + ], + } + return `<script type="application/ld+json">${JSON.stringify(data).replace(/</g, "\\u003c")}</script>` +} + +// Fails the build rather than shipping a page that breaks the on-page rules. +function check(name: string, html: string, title: string, description: string) { + const h1s = (html.slice(html.indexOf("<body")).match(/<h1[\s>]/g) ?? []).length + const problems = [ + title.length > 60 && `title is ${title.length} characters (max 60)`, + (!description || description.length > 155) && `description is ${description.length} characters (max 155)`, + h1s !== 1 && `${h1s} h1 elements (want 1)`, + ].filter(Boolean) + if (problems.length) throw new Error(`prerender: ${name}: ${problems.join("; ")}`) +} + +const template = await Bun.file(path.join(dist, "index.html")).text() +const slot = '<div id="root"></div>' +if (!template.includes(slot)) throw new Error(`prerender: ${slot} not found in dist/index.html`) + +for (const name of order) { + const page = pages[name] + if (!page) throw new Error(`prerender: docs.json lists ${name}, which has no page`) + const title = documentTitle(page.title, name) + const html = withHead(template, { title, description: page.description, url: pageUrl(name) }) + .replace("</head>", () => structuredData(page, title) + "\n </head>") + .replace(slot, () => `<div id="root">${render(BASE + "/" + name)}</div>`) + check(name, html, title, page.description) + await Bun.write(path.join(dist, name === "index" ? "index.html" : name + ".html"), html) +} + +// Unknown /docs paths are rewritten here by the site's vercel.json. +const missing = withHead(template, { + title: documentTitle("Page not found"), + description: "This documentation page does not exist or has moved.", + url: SITE + BASE, +}) + .replace( + /(<meta\s+name="robots"\s+content=")[^"]*(")/, + (_match, before: string, after: string) => before + "noindex" + after, + ) + .replace(slot, () => `<div id="root">${render(BASE + "/404")}</div>`) +await Bun.write(path.join(dist, "404.html"), missing) + +await Bun.write( + path.join(dist, "sitemap.xml"), + `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> +${order.map((name) => ` <url>\n <loc>${pageUrl(name)}</loc>\n </url>`).join("\n")} +</urlset> +`, +) +console.log(`Prerendered ${order.length} documentation pages, 404.html and sitemap.xml.`) diff --git a/frontend/docs/src/App.tsx b/frontend/docs/src/App.tsx index 853a10045..b69f55d81 100644 --- a/frontend/docs/src/App.tsx +++ b/frontend/docs/src/App.tsx @@ -1,5 +1,5 @@ import { DocumentationPage } from "./DocsApp" -export function App() { - return <DocumentationPage /> +export function App({ initialPath }: { initialPath?: string } = {}) { + return <DocumentationPage initialPath={initialPath} /> } diff --git a/frontend/docs/src/DocsApp.tsx b/frontend/docs/src/DocsApp.tsx index 1ce6c69cd..dceca9832 100644 --- a/frontend/docs/src/DocsApp.tsx +++ b/frontend/docs/src/DocsApp.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react" +import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react" import ReactMarkdown, { type Components } from "react-markdown" import remarkGfm from "remark-gfm" import { @@ -24,7 +24,7 @@ import { Terminal, } from "lucide-react" import { useTheme } from "./theme" -import { headings, pageHref, parseRoute, resolveLink, slug, type Route } from "./navigation" +import { documentTitle, headings, pageHref, parseRoute, resolveLink, slug, type Route } from "./navigation" const mono = `"JetBrains Mono", "SF Mono", ui-monospace, monospace` @@ -188,8 +188,8 @@ const SECTION_CONFIGS: Record<SectionKey, DocsConfig> = { openscience: RAW_CONFIGS["./content/openscience/docs.json"], } -function routeFromHash(): Route { - return parseRoute(typeof window === "undefined" ? "" : window.location.hash) +function routeFromLocation(): Route { + return parseRoute(typeof window === "undefined" ? "" : window.location.pathname + window.location.hash) } // The renderer uses the current route for page-relative Markdown links. @@ -502,9 +502,15 @@ function renderMintlifyContent(source: string): ReactNode[] { return nodes } -export function DocumentationPage() { +export const DOC_PAGES = SECTION_DOC_PAGES.openscience +export const DOC_CONFIG = SECTION_CONFIGS.openscience + +/* initialPath is set when prerendering; in the browser the route comes from the URL. */ +export function DocumentationPage({ initialPath }: { initialPath?: string } = {}) { const { theme, toggle: toggleTheme } = useTheme() - const [route, setRouteState] = useState<Route>(() => routeFromHash()) + const [route, setRouteState] = useState<Route>(() => + initialPath === undefined ? routeFromLocation() : parseRoute(initialPath), + ) const section = route.section CURRENT_SECTION = section CURRENT_ROUTE = route @@ -584,23 +590,52 @@ export function DocumentationPage() { .map((result) => result.page) }, [query, section]) - const navigate = (next: Route) => { - window.location.hash = pageHref(next.section, next.path, next.anchor) + const navigate = useCallback((next: Route) => { + const href = pageHref(next.section, next.path, next.anchor) + if (window.location.pathname + window.location.hash !== href) window.history.pushState(null, "", href) setRouteState(next) setMenuOpen(false) setSearchOpen(false) - } + }, []) useEffect(() => { - const onHashChange = () => setRouteState(routeFromHash()) - window.addEventListener("hashchange", onHashChange) - return () => window.removeEventListener("hashchange", onHashChange) + const onLocation = () => setRouteState(routeFromLocation()) + window.addEventListener("popstate", onLocation) + window.addEventListener("hashchange", onLocation) + return () => { + window.removeEventListener("popstate", onLocation) + window.removeEventListener("hashchange", onLocation) + } }, []) - // Keep the URL canonical (legacy + bare hashes resolve to #/<section>/<page>). + // Links between docs pages switch the page in place instead of loading its prerendered HTML. + useEffect(() => { + const onClick = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return + const link = (event.target as Element | null)?.closest?.("a") + if (!link || link.target || link.hasAttribute("download")) return + const url = new URL(link.href, window.location.href) + if (url.origin !== window.location.origin || !/^\/docs(\/|$)/.test(url.pathname) || /\.\w+$/.test(url.pathname)) + return + event.preventDefault() + navigate(parseRoute(url.pathname + url.hash)) + } + document.addEventListener("click", onClick) + return () => document.removeEventListener("click", onClick) + }, [navigate]) + + // Keep the URL canonical (a trailing slash or a renamed page resolves to /docs/<page>). useEffect(() => { const canonical = pageHref(route.section, route.path, route.anchor) - if (window.location.hash !== canonical) { + if (window.location.pathname + window.location.hash !== canonical) { window.history.replaceState(null, "", canonical) } }, [route]) @@ -615,9 +650,9 @@ export function DocumentationPage() { }, [route.path, route.anchor]) useEffect(() => { - document.title = activePage.title + " · OpenScience Docs" + document.title = documentTitle(activePage.title, activePage.path) document.querySelector('meta[name="description"]')?.setAttribute("content", activePage.description) - }, [activePage.title, activePage.description]) + }, [activePage.title, activePage.path, activePage.description]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { diff --git a/frontend/docs/src/entry-server.tsx b/frontend/docs/src/entry-server.tsx new file mode 100644 index 000000000..36ae81cce --- /dev/null +++ b/frontend/docs/src/entry-server.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react" +import { renderToString } from "react-dom/server" +import { App } from "./App" + +export { DOC_CONFIG, DOC_PAGES } from "./DocsApp" + +export function render(path: string): string { + return renderToString( + <StrictMode> + <App initialPath={path} /> + </StrictMode>, + ) +} diff --git a/frontend/docs/src/main.tsx b/frontend/docs/src/main.tsx index 90b1fc407..3f67eaf37 100644 --- a/frontend/docs/src/main.tsx +++ b/frontend/docs/src/main.tsx @@ -1,10 +1,20 @@ import React from "react" -import { createRoot } from "react-dom/client" +import { createRoot, hydrateRoot } from "react-dom/client" import { App } from "./App" +import { movedHref } from "./navigation" import "./styles/index.css" -createRoot(document.getElementById("root")!).render( - <React.StrictMode> - <App /> - </React.StrictMode>, -) +const moved = movedHref(window.location.pathname, window.location.hash) +if (moved) { + window.location.replace(moved) +} else { + const root = document.getElementById("root")! + const app = ( + <React.StrictMode> + <App /> + </React.StrictMode> + ) + // Production builds ship every page prerendered (script/prerender.ts); the dev server doesn't. + if (root.hasChildNodes()) hydrateRoot(root, app) + else createRoot(root).render(app) +} diff --git a/frontend/docs/src/navigation.ts b/frontend/docs/src/navigation.ts index 32166b7b6..9a6fbf273 100644 --- a/frontend/docs/src/navigation.ts +++ b/frontend/docs/src/navigation.ts @@ -1,5 +1,10 @@ export type Route = { section: "openscience"; path: string; anchor?: string } +// Pages live at /docs/<page>. Before that they lived at /docs/#/openscience/<page>, +// and those links (in the CLI, the apps, READMEs) still resolve through parseRoute. +export const BASE = "/docs" +export const SITE = "https://www.openscience.sh" + export const aliases: Record<string, string> = { "first-session": "sessions", "sub-agents": "agents", @@ -30,17 +35,60 @@ function decode(value: string): string { } } -export function parseRoute(hash: string): Route { - const raw = hash.replace(/^#\/?/, "") - const split = raw.indexOf("#") - const location = decode(split < 0 ? raw : raw.slice(0, split)).replace(/\/$/, "") - const anchor = split < 0 ? undefined : decode(raw.slice(split + 1)) || undefined +function route(location: string, anchor: string | undefined): Route { const path = location.replace(/^(openscience|agent-cli|cli)(\/|$)/, "") || "index" - return { section: "openscience", path: aliases[path] ?? path, ...(anchor ? { anchor } : {}) } + return { + section: "openscience", + path: Object.hasOwn(aliases, path) ? aliases[path] : path, + ...(anchor ? { anchor } : {}), + } } -export function pageHref(section: Route["section"], path: string, anchor?: string): string { - return "#/" + section + "/" + path + (anchor ? "#" + encodeURIComponent(anchor) : "") +/* Accepts a docs path ("/docs/sessions#exit-codes") or a legacy hash route + ("#/openscience/sessions#exit-codes", "#sessions"). */ +export function parseRoute(location: string): Route { + if (location.startsWith("#")) { + const raw = location.replace(/^#\/?/, "") + const split = raw.indexOf("#") + const path = decode(split < 0 ? raw : raw.slice(0, split)).replace(/\/$/, "") + return route(path, split < 0 ? undefined : decode(raw.slice(split + 1)) || undefined) + } + const split = location.indexOf("#") + const pathname = split < 0 ? location : location.slice(0, split) + const path = decode(pathname) + .replace(new RegExp("^" + BASE + "(?=/|$)"), "") + .replace(/^\/+|\/+$/g, "") + return route(path, split < 0 ? undefined : decode(location.slice(split + 1)) || undefined) +} + +// Where an old link now lives: /docs/#/openscience/<page>, /docs/#<renamed page>, or /docs/<renamed page>. +export function movedHref(pathname: string, hash: string): string | null { + const atRoot = new RegExp("^" + BASE + "/?$").test(pathname) + if (hash.startsWith("#/") || (atRoot && Object.hasOwn(aliases, decode(hash.slice(1))))) { + const target = parseRoute(hash) + return pageHref(target.section, target.path, target.anchor) + } + const leaf = decode(pathname) + .replace(new RegExp("^" + BASE + "/?"), "") + .replace(/\/+$/, "") + return Object.hasOwn(aliases, leaf) ? pageHref("openscience", aliases[leaf]) + hash : null +} + +export function pageHref(_section: Route["section"], path: string, anchor?: string): string { + return (path === "index" ? BASE : BASE + "/" + path) + (anchor ? "#" + encodeURIComponent(anchor) : "") +} + +export function pageUrl(path: string): string { + return SITE + pageHref("openscience", path) +} + +// Search results show about 60 characters of a title; the suffix gives way first. +export function documentTitle(title: string, path?: string): string { + if (path === "index") return "OpenScience Docs" + for (const candidate of [title + " · OpenScience Docs", title + " · OpenScience"]) { + if (candidate.length <= 60) return candidate + } + return title } export function resolveLink(href: string | undefined, current: Route): string | undefined { @@ -48,8 +96,8 @@ export function resolveLink(href: string | undefined, current: Route): string | if (href.startsWith("#") && !href.startsWith("#/")) { return pageHref(current.section, current.path, decode(href.slice(1))) } - const route = parseRoute(href.replace(/^\//, "#/")) - return pageHref(route.section, route.path.replace(/\.(mdx|md)$/, ""), route.anchor) + const target = parseRoute(href.startsWith("#") ? href : "/" + href.replace(/^\//, "")) + return pageHref(target.section, target.path.replace(/\.(mdx|md)$/, ""), target.anchor) } export function headings(markdown: string, depth = 2): string[] { diff --git a/frontend/docs/src/theme.ts b/frontend/docs/src/theme.ts index 52672d971..c2c6ac3d3 100644 --- a/frontend/docs/src/theme.ts +++ b/frontend/docs/src/theme.ts @@ -1,7 +1,9 @@ -import { useEffect, useState } from "react" +import { useEffect, useLayoutEffect, useState } from "react" export type Theme = "light" | "dark" +const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect + function readStoredTheme(): Theme { if (typeof window === "undefined") return "light" try { @@ -20,7 +22,10 @@ function applyTheme(theme: Theme) { } export function useTheme() { - const [theme, setTheme] = useState<Theme>(readStoredTheme) + // Starts light so hydration matches the prerendered page. The inline script in + // index.html has already applied the stored theme; this syncs the toggle before paint. + const [theme, setTheme] = useState<Theme>("light") + useIsoLayoutEffect(() => setTheme(readStoredTheme()), []) useEffect(() => { applyTheme(theme) try { diff --git a/frontend/docs/test/navigation.test.ts b/frontend/docs/test/navigation.test.ts index 30cd0c729..fdad9c0ad 100644 --- a/frontend/docs/test/navigation.test.ts +++ b/frontend/docs/test/navigation.test.ts @@ -1,23 +1,36 @@ import { describe, expect, test } from "bun:test" -import { aliases, headings, pageHref, parseRoute, resolveLink } from "../src/navigation" +import { aliases, documentTitle, headings, movedHref, pageHref, parseRoute, resolveLink } from "../src/navigation" describe("documentation routes", () => { - test("keeps the page when navigating to a section", () => { + test("pages have their own paths, with anchors for sections", () => { const href = resolveLink("/openscience/sessions#exit-codes", { section: "openscience", path: "index" }) - expect(href).toBe("#/openscience/sessions#exit-codes") + expect(href).toBe("/docs/sessions#exit-codes") expect(parseRoute(href!)).toEqual({ section: "openscience", path: "sessions", anchor: "exit-codes" }) expect(resolveLink("#exit-codes", { section: "openscience", path: "sessions" })).toBe(href) + expect(resolveLink("quickstart.mdx", { section: "openscience", path: "index" })).toBe("/docs/quickstart") + expect(pageHref("openscience", "index")).toBe("/docs") + expect(parseRoute("/docs")).toEqual({ section: "openscience", path: "index" }) + expect(parseRoute("/docs/")).toEqual({ section: "openscience", path: "index" }) }) - test("retains old page and section links", () => { + test("retains old hash links, renamed pages and section prefixes", () => { for (const [old, current] of Object.entries(aliases)) { expect(parseRoute("#/openscience/" + old).path).toBe(current) expect(parseRoute("#/agent-cli/" + old).path).toBe(current) expect(parseRoute("#" + old).path).toBe(current) + expect(parseRoute("/docs/" + old).path).toBe(current) + expect(movedHref("/docs/", "#" + old)).toBe("/docs/" + current) + expect(movedHref("/docs/" + old, "")).toBe("/docs/" + current) } + expect(movedHref("/docs/", "#/openscience/sessions#exit-codes")).toBe("/docs/sessions#exit-codes") + expect(movedHref("/docs/", "#/openscience/index")).toBe("/docs") + expect(movedHref("/docs/sessions", "#exit-codes")).toBeNull() + expect(movedHref("/docs", "#quickstart")).toBeNull() + expect(movedHref("/docs/constructor", "")).toBeNull() }) test("unknown and malformed routes remain recoverable", () => { expect(parseRoute("#/openscience/missing").path).toBe("missing") expect(parseRoute("#/openscience/%ZZ").path).toBe("%ZZ") + expect(parseRoute("/docs/%ZZ").path).toBe("%ZZ") expect(parseRoute("")).toEqual({ section: "openscience", path: "index" }) expect(parseRoute("#/openscience/")).toEqual({ section: "openscience", path: "index" }) }) @@ -27,6 +40,14 @@ describe("documentation routes", () => { expect(resolveLink("mailto:help@example.org", route)).toBe("mailto:help@example.org") expect(parseRoute(pageHref("openscience", "models", "a b")).anchor).toBe("a b") }) + test("titles fit in search results", () => { + expect(documentTitle("Quickstart")).toBe("Quickstart · OpenScience Docs") + expect(documentTitle("OpenScience", "index")).toBe("OpenScience Docs") + expect(documentTitle("A title long enough to drop the Docs suffix")).toBe( + "A title long enough to drop the Docs suffix · OpenScience", + ) + expect(documentTitle("x".repeat(70))).toBe("x".repeat(70)) + }) }) test("all real headings are indexed, but example headings are excluded", () => { diff --git a/frontend/landing/index.html b/frontend/landing/index.html index 465a88050..fa00c6957 100644 --- a/frontend/landing/index.html +++ b/frontend/landing/index.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>OpenScience | The open-source AI workbench for scientific research + OpenScience | Open-source AI co-scientist for research ({ "@type": "ListItem", position: i + 1, ...c })), + }, + ] + : []), + ] + const json = JSON.stringify({ "@context": "https://schema.org", "@graph": graph }).replace(/${json}` +} + +// Fails the build rather than shipping a page that breaks the on-page rules. +function check(path, html, { title, description }) { + const h1s = (html.slice(html.indexOf("]/g) ?? []).length + const problems = [ + title.length > 60 && `title is ${title.length} characters (max 60)`, + (!description || description.length > 155) && `description is ${description.length} characters (max 155)`, + h1s !== 1 && `${h1s} h1 elements (want 1)`, + ].filter(Boolean) + if (problems.length) throw new Error(`prerender: ${path}: ${problems.join("; ")}`) +} + const pages = [] for (const path of Object.keys(ROUTES)) { const { html, meta } = render(path) if (!meta) throw new Error(`prerender: ${path} rendered without calling useMeta`) const url = path === "/" ? `${CANONICAL}/` : `${CANONICAL}${path}` - const page = withHead(template, { ...meta, url }).replace(slot, () => `
${html}
`) + const page = withHead(template, { ...meta, url }) + .replace("", () => `${pageData({ path, url, ...meta })}\n `) + .replace(slot, () => `
${html}
`) + check(path, page, meta) await writeFile(resolve(path === "/" ? "dist/index.html" : `dist${path}.html`), page) pages.push({ path, url, ...meta }) } @@ -51,7 +100,8 @@ await writeFile( withHead(template, home).replace(slot, () => `
${render("/").html}
`), ) -const urls = [...pages.map((p) => p.url), `${CANONICAL}/docs`] +// Docs pages are listed in /docs/sitemap.xml, written by the docs build. +const urls = pages.map((p) => p.url) await writeFile( resolve("dist/sitemap.xml"), ` diff --git a/frontend/landing/src/data/links.ts b/frontend/landing/src/data/links.ts index b34da7648..a9afe3e35 100644 --- a/frontend/landing/src/data/links.ts +++ b/frontend/landing/src/data/links.ts @@ -14,7 +14,7 @@ export const NPM = "https://www.npmjs.com/package/@synsci/openscience" export const NPM_SDK = "https://www.npmjs.com/package/@synsci/sdk" export const DOCS = `${SITE}/docs` -export const docs = (page: string, hash = "") => `${DOCS}/#/openscience/${page}${hash}` +export const docs = (page: string, hash = "") => (page === "index" ? DOCS : `${DOCS}/${page}`) + hash export const SYNTHETIC_SCIENCES = "https://syntheticsciences.ai" export const DASHBOARD = "https://app.syntheticsciences.ai" diff --git a/frontend/landing/src/pages/Benchmark.tsx b/frontend/landing/src/pages/Benchmark.tsx index f20d09dbd..af0c2c9e5 100644 --- a/frontend/landing/src/pages/Benchmark.tsx +++ b/frontend/landing/src/pages/Benchmark.tsx @@ -195,8 +195,8 @@ function Stat({ export default function Benchmark() { useMeta({ - title: "OpenScience | Benchmarks: the #1 scientific agent, open source", - description: `OpenScience solves ${N.tbs_pct}% of Terminal-Bench Science, ${N.tb4_pct}% of the science tasks in Terminal-Bench 4.0, and averages ${N.bio_mean} on BiomniBench-DA. Every figure, trace and method.`, + title: "OpenScience | Benchmarks: the #1 scientific agent", + description: `OpenScience solves ${N.tbs_pct}% of Terminal-Bench Science and ${N.tb4_pct}% of Terminal-Bench 4.0 science tasks, and averages ${N.bio_mean} on BiomniBench-DA.`, path: "/benchmark", }) diff --git a/frontend/landing/src/pages/Landing.tsx b/frontend/landing/src/pages/Landing.tsx index c94844f78..3200c1e78 100644 --- a/frontend/landing/src/pages/Landing.tsx +++ b/frontend/landing/src/pages/Landing.tsx @@ -198,7 +198,7 @@ const FAQ = [ export default function Landing() { useMeta({ - title: "OpenScience | The open-source AI workbench for scientific research", + title: "OpenScience | Open-source AI co-scientist for research", description: "OpenScience is the open-source AI co-scientist. Free models included or connect any model from any provider, including Claude, GPT, Gemini and more.", path: "/", diff --git a/frontend/landing/src/pages/Privacy.tsx b/frontend/landing/src/pages/Privacy.tsx index 3beec6da4..341e8afd7 100644 --- a/frontend/landing/src/pages/Privacy.tsx +++ b/frontend/landing/src/pages/Privacy.tsx @@ -63,7 +63,7 @@ export default function Privacy() { Shell commands, Python and R kernels, and local compute jobs run inside macOS Seatbelt or Linux bubblewrap when containment is on, with network egress denied. Known credential patterns are redacted from tool output before it is written into a session. The full picture is in the{" "} - security documentation. + security documentation.

What leaves your machine

diff --git a/frontend/landing/vercel.json b/frontend/landing/vercel.json index 298feb29f..ac7c09a5b 100644 --- a/frontend/landing/vercel.json +++ b/frontend/landing/vercel.json @@ -35,6 +35,11 @@ "source": "/:page(download|ace|benchmark|privacy)/", "destination": "/:page", "permanent": true + }, + { + "source": "/docs/", + "destination": "/docs", + "permanent": true } ], "rewrites": [ @@ -44,7 +49,7 @@ }, { "source": "/docs/:path*", - "destination": "/docs/index.html" + "destination": "/docs/404.html" } ] }