diff --git a/apps/docs/package.json b/apps/docs/package.json index 1351b117e..b812fb206 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -33,7 +33,7 @@ "preview": "yarn build && static-server build/client", "serve:build": "static-server --host 127.0.0.1 --port 4173 build/client", "reanalyze": "rescript-tools reanalyze -all-cmt .", - "test": "node scripts/test.mjs", + "test": "yarn build:res && yarn build:scripts && yarn build:generate-llms && node scripts/test.mjs", "test:e2e": "yarn build && bash ../../.github/scripts/homepage-e2e.sh", "cy:open": "yarn build:res && cypress open --e2e --browser electron", "cy:run": "yarn build:res && cypress run", diff --git a/apps/docs/public/_redirects b/apps/docs/public/_redirects index c82a19b89..1b1804ed1 100644 --- a/apps/docs/public/_redirects +++ b/apps/docs/public/_redirects @@ -2,31 +2,51 @@ /bucklescript-rebranding /blog/bucklescript-is-rebranding 308 /docs/guidelines/publishing-packages /docs/guides/publishing-packages 308 -/docs/manual/v12.0.0/* /docs/manual/:splat 308 -/docs/manual/next/* /docs/manual/:splat 308 -/docs/react/latest/* /docs/react/:splat 308 - +# Legacy aliases serve the current deployment's generated manual text files. /llms/manual/llms.txt /llms.txt 307 +/llms/manual/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/llms-small.txt /llms/manual/llm-small.txt 307 /llms/manual/latest/llms.txt /llms.txt 307 +/llms/manual/latest/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/latest/llms-small.txt /llms/manual/llm-small.txt 307 /llms/manual/next/llms.txt /llms.txt 307 -/llms/manual/v13.0.0/llms.txt /llms/manual/v13/llms.txt 307 -/llms/manual/v13.0.0/llms-full.txt /llms/manual/v13/llm-full.txt 307 -/llms/manual/v13.0.0/llms-small.txt /llms/manual/v13/llm-small.txt 307 -/llms/manual/v13.0.0/* /llms/manual/v13/:splat 307 -/llms/manual/v12.0.0/llms.txt /llms/manual/v12/llms.txt 307 -/llms/manual/v12.0.0/llms-full.txt /llms/manual/v12/llm-full.txt 307 -/llms/manual/v12.0.0/llms-small.txt /llms/manual/v12/llm-small.txt 307 -/llms/manual/v12.0.0/* /llms/manual/v12/:splat 307 -/llms/manual/v11/llms.txt /llms/manual/v12/llms.txt 307 -/llms/manual/v11/llms-full.txt /llms/manual/v12/llm-full.txt 307 -/llms/manual/v11/llms-small.txt /llms/manual/v12/llm-small.txt 307 -/llms/manual/v11/* /llms/manual/v12/:splat 307 -/llms/manual/v11.0.0/llms.txt /llms/manual/v12/llms.txt 307 -/llms/manual/v11.0.0/llms-full.txt /llms/manual/v12/llm-full.txt 307 -/llms/manual/v11.0.0/llms-small.txt /llms/manual/v12/llm-small.txt 307 -/llms/manual/v11.0.0/* /llms/manual/v12/:splat 307 +/llms/manual/next/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/next/llms-small.txt /llms/manual/llm-small.txt 307 +/llms/manual/v13.0.0/llms.txt /llms.txt 307 +/llms/manual/v13.0.0/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/v13.0.0/llms-small.txt /llms/manual/llm-small.txt 307 +/llms/manual/v13/llms.txt /llms.txt 307 +/llms/manual/v13/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/v13/llms-small.txt /llms/manual/llm-small.txt 307 +/llms/manual/v12.0.0/llms.txt /llms.txt 307 +/llms/manual/v12.0.0/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/v12.0.0/llms-small.txt /llms/manual/llm-small.txt 307 +/llms/manual/v12/llms.txt /llms.txt 307 +/llms/manual/v12/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/v12/llms-small.txt /llms/manual/llm-small.txt 307 +/llms/manual/v11/llms.txt /llms.txt 307 +/llms/manual/v11/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/v11/llms-small.txt /llms/manual/llm-small.txt 307 +/llms/manual/v11.0.0/llms.txt /llms.txt 307 +/llms/manual/v11.0.0/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/v11.0.0/llms-small.txt /llms/manual/llm-small.txt 307 +/llms/manual/v10/llms.txt /llms.txt 307 +/llms/manual/v10/llms-full.txt /llms/manual/llm-full.txt 307 +/llms/manual/v10/llms-small.txt /llms/manual/llm-small.txt 307 + +# Explicit aliases precede wildcard rules. +/docs/manual/v12.0.0/* /docs/manual/:splat 308 +/docs/manual/next/* /docs/manual/:splat 308 +/docs/react/latest/* /docs/react/:splat 308 /llms/manual/latest/* /llms/manual/:splat 307 /llms/manual/next/* /llms/manual/:splat 307 +/llms/manual/v13.0.0/* /llms/manual/:splat 307 +/llms/manual/v13/* /llms/manual/:splat 307 +/llms/manual/v12.0.0/* /llms/manual/:splat 307 +/llms/manual/v12/* /llms/manual/:splat 307 +/llms/manual/v11/* /llms/manual/:splat 307 +/llms/manual/v11.0.0/* /llms/manual/:splat 307 +/llms/manual/v10/* /llms/manual/:splat 307 /docs/manual/v11.0.0/* https://v11.rescript-lang.org/docs/manual/v11.0.0/:splat 308 /docs/manual/v10.0.0/* https://v11.rescript-lang.org/docs/manual/v10.0.0/:splat 308 diff --git a/apps/docs/scripts/__tests__/generate-llms.test.mjs b/apps/docs/scripts/__tests__/generate-llms.test.mjs index 0a2661b1d..fd2de3c02 100644 --- a/apps/docs/scripts/__tests__/generate-llms.test.mjs +++ b/apps/docs/scripts/__tests__/generate-llms.test.mjs @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { checkRedirects } from "../test-redirects.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -19,6 +20,11 @@ let writeFile = (root, filePath, content) => { let makeWorkspace = () => { let root = fs.mkdtempSync(path.join(os.tmpdir(), "generate llms-")); + writeFile( + root, + "public/_redirects", + fs.readFileSync(path.join(docsRoot, "public/_redirects"), "utf8"), + ); writeFile( root, @@ -206,6 +212,12 @@ order: 4 let readFile = (root, filePath) => fs.readFileSync(path.join(root, filePath), "utf8"); +let checkWorkspaceRedirects = (root) => + checkRedirects({ + redirectsPath: path.join(root, "public", "_redirects"), + publicDirectory: path.join(root, "public"), + }); + test("generate_llms writes the default manual index at the site root", () => { let root = makeWorkspace(); @@ -213,6 +225,7 @@ test("generate_llms writes the default manual index at the site root", () => { cwd: root, stdio: "pipe", }); + checkWorkspaceRedirects(root); let currentLlms = readFile(root, "public/llms.txt"); let humanLlmsPage = readFile(root, "markdown-pages/docs/manual/llms.mdx"); @@ -344,6 +357,33 @@ test("generate_llms writes the default manual index at the site root", () => { assert.doesNotMatch(currentLlms, /v10\.|v11\.|v12\.|v13\./); }); +test("redirect checks reject a target removed after LLM generation", (t) => { + const root = makeWorkspace(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + child_process.execFileSync(process.execPath, [generatorPath], { cwd: root }); + fs.unlinkSync(path.join(root, "public/llms/manual/llm-full.txt")); + assert.throws( + () => checkWorkspaceRedirects(root), + /Missing generated LLM text file.*\/llms\/manual\/llm-full\.txt/, + ); +}); + +test("redirect checks reject a wildcard that shadows the manual index", (t) => { + const root = makeWorkspace(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + child_process.execFileSync(process.execPath, [generatorPath], { cwd: root }); + const redirects = readFile(root, "public/_redirects"); + writeFile( + root, + "public/_redirects", + `/llms/manual/v12.0.0/* /llms/manual/:splat 307\n${redirects}`, + ); + assert.throws( + () => checkWorkspaceRedirects(root), + /First matching redirect for \/llms\/manual\/v12\.0\.0\/llms\.txt/, + ); +}); + test("generate_llms writes ReScript React files", () => { let root = makeWorkspace(); diff --git a/apps/docs/scripts/test-redirects.mjs b/apps/docs/scripts/test-redirects.mjs index 6999dafed..0d3536a2a 100644 --- a/apps/docs/scripts/test-redirects.mjs +++ b/apps/docs/scripts/test-redirects.mjs @@ -5,97 +5,105 @@ import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const redirectsPath = path.resolve(__dirname, "../public/_redirects"); -const entries = fs - .readFileSync(redirectsPath, "utf8") - .split(/\r?\n/) - .map((line, index) => ({ index, line: line.trim() })) - .filter(({ line }) => line !== "" && !line.startsWith("#")) - .map(({ index, line }) => { - const [source, destination, status] = line.split(/\s+/); - return { index, source, destination, status }; - }); +export function checkRedirects({ + redirectsPath = path.resolve(__dirname, "../public/_redirects"), + publicDirectory = path.resolve(__dirname, "../public"), +} = {}) { + const entries = fs + .readFileSync(redirectsPath, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== "" && !line.startsWith("#")) + .map((line) => { + const [source, destination, status] = line.split(/\s+/); + return { source, destination, status }; + }); -let findEntry = (source) => { - const entry = entries.find((entry) => entry.source === source); - assert.ok(entry, `Missing redirect for ${source}`); - return entry; -}; + const assertRedirect = (source, destination, status) => { + const entry = entries.find((entry) => entry.source === source); + assert.ok(entry, `Missing redirect for ${source}`); + assert.equal(entry.destination, destination, source); + assert.equal(entry.status, status, source); + return entry; + }; -let assertRedirect = (source, destination, status) => { - const entry = findEntry(source); - assert.equal(entry.destination, destination); - assert.equal(entry.status, status); - return entry; -}; + const assertTextTarget = (source, destination) => { + // Cloudflare uses the first matching rule, including wildcard rules. + const entry = entries.find(({ source: pattern }) => + pattern.endsWith("*") + ? source.startsWith(pattern.slice(0, -1)) + : pattern === source, + ); + assert.ok(entry, `Missing redirect for ${source}`); + const splat = source.slice(entry.source.length - 1); + assert.equal( + entry.destination.replace(":splat", splat), + destination, + `First matching redirect for ${source}`, + ); + assert.equal(entry.status, "307", source); -let assertManualLlmsVersionRedirects = (sourceVersion, destinationVersion) => { - const sourcePrefix = `/llms/manual/${sourceVersion}`; - const destinationPrefix = `/llms/manual/${destinationVersion}`; - const index = assertRedirect( - `${sourcePrefix}/llms.txt`, - `${destinationPrefix}/llms.txt`, - "307", - ); - const full = assertRedirect( - `${sourcePrefix}/llms-full.txt`, - `${destinationPrefix}/llm-full.txt`, - "307", - ); - const small = assertRedirect( - `${sourcePrefix}/llms-small.txt`, - `${destinationPrefix}/llm-small.txt`, - "307", - ); - const wildcard = assertRedirect( - `${sourcePrefix}/*`, - `${destinationPrefix}/:splat`, - "307", - ); + const target = path.join(publicDirectory, destination.slice(1)); + assert.ok( + fs.existsSync(target) && fs.statSync(target).isFile(), + `Missing generated LLM text file for ${source}: ${destination}`, + ); + assert.ok( + fs.readFileSync(target, "utf8").trim().length > 0, + `Empty generated LLM text file: ${destination}`, + ); + }; - assert.ok( - index.index < wildcard.index, - `${sourcePrefix}/llms.txt must be listed before ${sourcePrefix}/*`, - ); - assert.ok( - full.index < wildcard.index, - `${sourcePrefix}/llms-full.txt must be listed before ${sourcePrefix}/*`, - ); - assert.ok( - small.index < wildcard.index, - `${sourcePrefix}/llms-small.txt must be listed before ${sourcePrefix}/*`, + assertRedirect( + "/docs/guidelines/publishing-packages", + "/docs/guides/publishing-packages", + "308", ); -}; -assertRedirect("/llms/manual/llms.txt", "/llms.txt", "307"); -assertRedirect( - "/docs/guidelines/publishing-packages", - "/docs/guides/publishing-packages", - "308", -); -const latestAlias = assertRedirect( - "/llms/manual/latest/llms.txt", - "/llms.txt", - "307", -); -const nextAlias = assertRedirect( - "/llms/manual/next/llms.txt", - "/llms.txt", - "307", -); -assertManualLlmsVersionRedirects("v13.0.0", "v13"); -assertManualLlmsVersionRedirects("v12.0.0", "v12"); -assertManualLlmsVersionRedirects("v11", "v12"); -assertManualLlmsVersionRedirects("v11.0.0", "v12"); + const textFiles = [ + ["llms.txt", "/llms.txt"], + ["llms-full.txt", "/llms/manual/llm-full.txt"], + ["llms-small.txt", "/llms/manual/llm-small.txt"], + ]; + for (const [file, destination] of textFiles) { + const source = `/llms/manual/${file}`; + assertRedirect(source, destination, "307"); + assertTextTarget(source, destination); + } -assert.ok( - latestAlias.index < findEntry("/llms/manual/latest/*").index, - "/llms/manual/latest/llms.txt must be listed before /llms/manual/latest/*", -); -assert.ok( - nextAlias.index < findEntry("/llms/manual/next/*").index, - "/llms/manual/next/llms.txt must be listed before /llms/manual/next/*", -); + for (const alias of [ + "latest", + "next", + "v13.0.0", + "v13", + "v12.0.0", + "v12", + "v11.0.0", + "v11", + "v10", + ]) { + const prefix = `/llms/manual/${alias}`; + for (const [file, destination] of textFiles) { + const source = `${prefix}/${file}`; + assertRedirect(source, destination, "307"); + assertTextTarget(source, destination); + } + assertRedirect(`${prefix}/*`, "/llms/manual/:splat", "307"); + for (const file of [ + "llm-full.txt", + "llm-small.txt", + "language-overview/llm.txt", + "javascript-interop/llm.txt", + "build-system/llm.txt", + "getting-started/llm.txt", + ]) { + assertTextTarget(`${prefix}/${file}`, `/llms/manual/${file}`); + } + } +} -console.log("✅ Redirect check complete. 0 issues found."); +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + checkRedirects(); + console.log("✅ Redirect check complete. 0 issues found."); +}