diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 9fecd800f..248ccb3dc 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -55,6 +55,8 @@ jobs: run: yarn - name: Build ReScript run: yarn build:res + - name: Build docs + run: yarn build:vite - name: Validate Docs Wrangler dev server working-directory: apps/docs run: | diff --git a/AGENTS.md b/AGENTS.md index bffff5514..051982a20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Overview -This is the official documentation website for the [ReScript](https://rescript-lang.org) programming language. It is a **fully pre-rendered static site** (no server-side rendering at runtime) built with **ReScript v12 + React 19 + React Router v8 + Vite 8 + Tailwind CSS v4**, deployed to **Cloudflare Pages**. +This is the official documentation website for the [ReScript](https://rescript-lang.org) programming language. It is primarily pre-rendered at build time, with runtime server-side rendering for `/try` through Cloudflare Pages Functions. It is built with **ReScript v12 + React 19 + React Router v8 + Vite 8 + Tailwind CSS v4**, deployed to **Cloudflare Pages**. ## System Requirements @@ -99,7 +99,8 @@ The project uses several patterns for JavaScript interop. Follow the existing co ## ReScript React - This project uses **React 19** and **React Router v8** (framework mode). -- The site is **pre-rendered** (`ssr: false`), so loaders have access to the filesystem during build. Loaders do **not** run on a server after the build. +- The site uses hybrid rendering: most routes are pre-rendered during the build, while `/try` is server-rendered by Cloudflare Pages Functions at runtime. +- Loaders for pre-rendered routes can access the filesystem during the build. Runtime SSR loaders, including shared loader code reachable from `/try`, execute in Cloudflare and must not depend on Node-only filesystem APIs or other build-time-only operations. - Route modules live in `app/routes/` and export a `loader` and a `default` component. - Route modules **require** both a `.res` and a `.resi` (interface) file for Vite HMR to work. - Only a single React component can be exposed from a module's JS output. diff --git a/apps/docs/__tests__/Sitemap_.test.res b/apps/docs/__tests__/Sitemap_.test.res index f35927387..a9f12ce7e 100644 --- a/apps/docs/__tests__/Sitemap_.test.res +++ b/apps/docs/__tests__/Sitemap_.test.res @@ -1,7 +1,7 @@ open Vitest test("renders sorted unique sitemap URLs with a normalized base URL", async () => { - let xml = Sitemap.render( + let xml = Sitemap.renderWithRuntimePaths( ~baseUrl="https://preview.example.com/", ["/docs/manual/introduction", "blog", "/", "/docs/manual/introduction"], ) @@ -17,6 +17,9 @@ test("renders sorted unique sitemap URLs with a normalized base URL", async () = https://preview.example.com/docs/manual/introduction/ + + https://preview.example.com/try/ + `) }) diff --git a/apps/docs/app/entry.server.js b/apps/docs/app/entry.server.js new file mode 100644 index 000000000..f00cc4976 --- /dev/null +++ b/apps/docs/app/entry.server.js @@ -0,0 +1,55 @@ +import React from "react"; +import { renderToReadableStream } from "react-dom/server"; +import { isbot } from "isbot"; +import { ServerRouter } from "react-router"; + +export const streamTimeout = 5_000; + +export default async function handleRequest( + request, + responseStatusCode, + responseHeaders, + routerContext, +) { + responseHeaders.set("Content-Type", "text/html"); + + if (request.method.toUpperCase() === "HEAD") { + return new Response(null, { + status: responseStatusCode, + headers: responseHeaders, + }); + } + + let shellRendered = false; + let userAgent = request.headers.get("user-agent"); + + let body = await renderToReadableStream( + React.createElement(ServerRouter, { + context: routerContext, + url: request.url, + }), + { + signal: AbortSignal.timeout(streamTimeout + 1_000), + onError(error) { + responseStatusCode = 500; + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + shellRendered = true; + + if ( + ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) && + body.allReady + ) { + await body.allReady; + } + + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} diff --git a/apps/docs/functions/try.data.js b/apps/docs/functions/try.data.js new file mode 100644 index 000000000..cdd6cbf71 --- /dev/null +++ b/apps/docs/functions/try.data.js @@ -0,0 +1 @@ +export { onRequest } from "../server/react-router-handler.js"; diff --git a/apps/docs/functions/try.js b/apps/docs/functions/try.js new file mode 100644 index 000000000..cdd6cbf71 --- /dev/null +++ b/apps/docs/functions/try.js @@ -0,0 +1 @@ +export { onRequest } from "../server/react-router-handler.js"; diff --git a/apps/docs/package.json b/apps/docs/package.json index a8cbb7fea..3baad8db8 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -23,7 +23,7 @@ "convert-images": "auto-convert-images", "dev:res": "rescript watch", "dev:vite": "react-router dev --host", - "dev:wrangler": "yarn wrangler pages dev build/client", + "dev:wrangler": "yarn build:vite && yarn wrangler pages dev build/client", "dev": "yarn prepare && yarn dev:res & yarn dev:vite & yarn dev:wrangler", "format": "cd ../.. && oxfmt && cd apps/docs && rescript format", "prepare": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index", diff --git a/apps/docs/react-router.config.mjs b/apps/docs/react-router.config.mjs index fab177c9c..1cbca953b 100644 --- a/apps/docs/react-router.config.mjs +++ b/apps/docs/react-router.config.mjs @@ -3,16 +3,23 @@ import * as fs from "node:fs"; const { stdlibPaths } = await import("./app/DocsRoutes.jsx"); export default { - ssr: false, + ssr: true, + routeDiscovery: { mode: "initial" }, prerender: { // Restore os.availableParallelism() after https://github.com/remix-run/react-router/issues/15255 is fixed. concurrency: 1, async paths({ getStaticPaths }) { - return [...(await getStaticPaths()), ...stdlibPaths]; + return [ + ...(await getStaticPaths()).filter( + (path) => path !== "/try" && path !== "try", + ), + ...stdlibPaths, + ]; }, }, buildEnd: async () => { + fs.rmSync("./out", { recursive: true, force: true }); fs.cpSync("./build/client", "./out", { recursive: true }); }, }; diff --git a/apps/docs/scripts/generate_sitemap.res b/apps/docs/scripts/generate_sitemap.res index d85dc9f44..1fbd70be2 100644 --- a/apps/docs/scripts/generate_sitemap.res +++ b/apps/docs/scripts/generate_sitemap.res @@ -36,7 +36,7 @@ if !Node.Fs.existsSync(sourceDir) { } let baseUrl = Node.Process.env->Dict.get("VITE_DEPLOYMENT_URL")->Option.getOr("") -let sitemap = sourceDir->collectPagePaths("")->Sitemap.render(~baseUrl) +let sitemap = sourceDir->collectPagePaths("")->Sitemap.renderWithRuntimePaths(~baseUrl) outputDirs->Array.forEach(outputDir => { if Node.Fs.existsSync(outputDir) { diff --git a/apps/docs/server/react-router-handler.js b/apps/docs/server/react-router-handler.js new file mode 100644 index 000000000..c0a8ac2df --- /dev/null +++ b/apps/docs/server/react-router-handler.js @@ -0,0 +1,12 @@ +import { createRequestHandler, RouterContextProvider } from "react-router"; + +const mode = globalThis.process?.env?.NODE_ENV ?? "production"; + +const handleReactRouterRequest = createRequestHandler( + () => import("../build/server/index.js"), + mode, +); + +export function onRequest(context) { + return handleReactRouterRequest(context.request, new RouterContextProvider()); +} diff --git a/apps/docs/src/common/Sitemap.res b/apps/docs/src/common/Sitemap.res index 2dfacc6b1..31571d85e 100644 --- a/apps/docs/src/common/Sitemap.res +++ b/apps/docs/src/common/Sitemap.res @@ -1,4 +1,5 @@ let defaultBaseUrl = "https://rescript-lang.org" +let runtimePaths = ["/try"] let normalizeBaseUrl = baseUrl => { let trimmed = baseUrl->String.trim @@ -79,3 +80,6 @@ ${urls} ` } + +let renderWithRuntimePaths = (~baseUrl, staticPaths) => + [...staticPaths, ...runtimePaths]->render(~baseUrl) diff --git a/apps/docs/wrangler.toml b/apps/docs/wrangler.toml index 2b2e8c6d0..2f9ad5f1a 100644 --- a/apps/docs/wrangler.toml +++ b/apps/docs/wrangler.toml @@ -1 +1,4 @@ +name = "rescript-lang-org" compatibility_date = "2026-09-10" +compatibility_flags = ["nodejs_compat"] +pages_build_output_dir = "out"