Skip to content

Commit 70ad620

Browse files
committed
Keep the Read the Docs URL scheme as canonical
Nothing changes for existing links — they serve content directly instead of redirecting: - the latest build mounts at /en/latest (default basePath), versioned builds at /en/vX.Y.Z, matching RTD's tag URLs - page routes carry the .html suffix via a custom loader url, so every internal link, sidebar entry, and search result points at the exact legacy URL (/en/latest/howto/select.html) and the exported file is the page itself — no edge rewrite rules needed - scripts/fix-html-ext.mjs renames Next's <route>.html.html exports and moves the per-segment prefetch dirs (which squat on the page's own path) to out-segments/; object storage holds both key shapes, and filesystem hosts fall back to the full-page RSC payload - redirects shrink to: / and /en/ → /en/latest/, plus the carried-over upload → push rule, all generated into out-root/ for the bucket root - version switcher and workflows updated for the /en/<version> prefixes; only /en/latest is indexable Verified with a headless-browser click-through: client-side navigation, anchored cross-page links, and versioned builds all work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Thc5nWNbxfQ67pVDcNSkxC
1 parent 361be00 commit 70ad620

12 files changed

Lines changed: 187 additions & 97 deletions

File tree

‎.github/workflows/deploy.yml‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,17 @@ jobs:
5454
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
5555
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
5656
AWS_DEFAULT_REGION: auto
57-
# The root prefix is the latest build; versioned snapshots live under
58-
# v*/ and versions.json is owned by the release workflow — never
59-
# delete either from here.
57+
# The latest build lives under en/latest/; versioned snapshots
58+
# (en/vX.Y.Z/) and versions.json belong to the release workflow.
59+
# out-segments/ holds per-segment prefetch payloads whose keys
60+
# (howto/select.html/__next.*.txt) can only coexist with the page
61+
# keys (howto/select.html) in object storage — upload it second, and
62+
# keep the first sync's --delete away from those keys.
6063
run: |
61-
aws s3 sync out/ "s3://${{ secrets.R2_BUCKET }}/" \
62-
--endpoint-url "https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com" \
63-
--delete \
64-
--exclude "v*" \
65-
--exclude "versions.json"
64+
ENDPOINT="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
65+
aws s3 sync out/ "s3://${{ secrets.R2_BUCKET }}/en/latest/" \
66+
--endpoint-url "$ENDPOINT" --delete --exclude "*.html/__next.*"
67+
aws s3 sync out-segments/ "s3://${{ secrets.R2_BUCKET }}/en/latest/" \
68+
--endpoint-url "$ENDPOINT"
69+
aws s3 cp out-root/ "s3://${{ secrets.R2_BUCKET }}/" \
70+
--endpoint-url "$ENDPOINT" --recursive

‎.github/workflows/release.yml‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ jobs:
5353
run: npm run build
5454
env:
5555
# Static export bakes absolute asset/link/search paths, so the
56-
# version prefix must be set at build time.
57-
NEXT_PUBLIC_BASE_PATH: /${{ env.TAG }}
56+
# version prefix must be set at build time. /en/vX.Y.Z matches the
57+
# old Read the Docs tag URLs.
58+
NEXT_PUBLIC_BASE_PATH: /en/${{ env.TAG }}
5859

5960
- uses: actions/upload-artifact@v4
6061
with:
@@ -70,8 +71,10 @@ jobs:
7071
ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
7172
BUCKET: ${{ secrets.R2_BUCKET }}
7273
run: |
73-
aws s3 sync out/ "s3://${BUCKET}/${TAG}/" \
74-
--endpoint-url "$ENDPOINT" --delete
74+
aws s3 sync out/ "s3://${BUCKET}/en/${TAG}/" \
75+
--endpoint-url "$ENDPOINT" --delete --exclude "*.html/__next.*"
76+
aws s3 sync out-segments/ "s3://${BUCKET}/en/${TAG}/" \
77+
--endpoint-url "$ENDPOINT"
7578
7679
# Append this version to versions.json at the domain root; every
7780
# snapshot fetches it at runtime, so old snapshots list new versions.

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ node_modules/
44
# Next.js build output
55
.next/
66
out/
7+
out-root/
8+
out-segments/
79

810
# fumadocs-mdx generated files
911
.source/

‎README.md‎

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -63,54 +63,65 @@ of the sidebar). Anything unexpected — an unknown `toc.yaml` field, a page
6363
without a title — fails the build loudly: that's the contract-drift alarm.
6464
`content/` is generated output; never commit or hand-edit it.
6565

66-
`npm run build` emits the static site to `out/` (plain HTML/CSS/JS, servable
67-
from any static host that resolves `/howto/select` to `howto/select.html`),
68-
then `scripts/redirects.mjs` adds redirect stubs for legacy URLs.
66+
`npm run build` emits the static site to `out/`, then the postbuild scripts
67+
align filenames with the `.html` routes (see below) and generate the
68+
domain-root redirect objects in `out-root/`.
6969

70-
## URL scheme and redirects
70+
## URL scheme
7171

72-
Pages are mounted at the domain root: `docs.sqlc.dev/howto/select`. The old
73-
Read the Docs URLs redirect:
72+
The site keeps the Read the Docs URL scheme byte-for-byte, so existing links
73+
never change or even redirect:
7474

7575
```
76-
/en/latest/<path>.html → /<path> (301)
77-
/en/latest/ → /
78-
/howto/upload → /howto/push (carried over from the old rediraffe config)
76+
/en/latest/howto/select.html the current docs (canonical)
77+
/en/latest/ section index
78+
/en/v1.32.0/howto/select.html versioned snapshots (same scheme RTD used for tags)
79+
/ redirects to /en/latest/
80+
/en/latest/howto/upload.html redirects to /en/latest/howto/push.html
81+
(carried over from the old rediraffe config)
7982
```
8083

81-
These should be real 301s at the hosting edge (e.g. Cloudflare Bulk
82-
Redirects). As a belt-and-braces fallback the build also emits meta-refresh
83-
stubs (with canonical links and `noindex`) under `out/en/latest/`, so legacy
84-
links work on any static host even before edge rules exist. Old RTD tag URLs
85-
(`/en/v1.29.0/...`) can redirect to `/` until versioned builds cover them.
84+
Page routes carry the `.html` suffix (a custom `url` in `lib/source.ts`), so
85+
every internal link, search result, and sidebar entry points at the exact
86+
legacy URL and the exported file *is* the page — no edge rewrite rules
87+
needed. Next's export writes `<route>.html.html` plus a per-segment prefetch
88+
directory squatting on the page's own path; `scripts/fix-html-ext.mjs`
89+
renames the former and moves the latter to `out-segments/`, whose keys can
90+
coexist with the page keys in object storage (flat keyspace) but not on a
91+
filesystem. Hosts serving `out/` alone (local preview, GitHub Pages) still
92+
work: the segment prefetch probes 404 and the client falls back to the
93+
full-page `.txt` payload.
8694

8795
## Versioning
8896

8997
Versions are immutable build artifacts, not branches:
9098

91-
- The root serves latest, rebuilt on every `main` docs change.
92-
- The release workflow builds a tag with `NEXT_PUBLIC_BASE_PATH=/v1.32.0`
99+
- `/en/latest` serves the current docs, rebuilt on every `main` docs change.
100+
- The release workflow builds a tag with `NEXT_PUBLIC_BASE_PATH=/en/v1.32.0`
93101
(static export bakes absolute asset/link/search paths, so the prefix is a
94-
build-time setting) and uploads it to the `/v1.32.0/` prefix, once, forever.
102+
build-time setting) and uploads it to the `en/v1.32.0/` prefix, once,
103+
forever.
95104
- `versions.json` at the domain root, appended by each release, drives the
96105
version-switcher dropdown; every snapshot fetches it at runtime so old
97106
snapshots list new versions.
98107
- Versioned builds set `noindex` so stale versions never outrank current
99108
docs in search engines.
100109
- Versioning starts at the first tag that contains `docs/toc.yaml`; older
101-
tags are not backfilled.
110+
tags are not backfilled. Old RTD tag URLs for those (`/en/v1.29.0/...`)
111+
can redirect to `/en/latest/` at the edge.
102112

103113
## CI
104114

105115
- **CI** (`ci.yml`): ingest + build + typecheck on every PR and push to main.
106116
- **Deploy latest docs** (`deploy.yml`): runs on `repository_dispatch`
107117
(type `docs-updated`), manual dispatch, and a daily cron as a safety net.
108-
Ingests sqlc@main, builds, syncs `out/` to the R2 bucket root — excluding
109-
`v*/` and `versions.json`, which belong to releases.
118+
Ingests sqlc@main, builds, syncs `out/` + `out-segments/` to the
119+
`en/latest/` prefix and the `out-root/` redirect objects to the bucket
120+
root.
110121
- **Deploy versioned docs** (`release.yml`): takes a tag (manual input or
111122
`repository_dispatch` type `docs-release` with `{"tag": "v1.32.0"}`),
112-
builds it under its version prefix, syncs to that prefix, and appends the
113-
tag to `versions.json`.
123+
builds it under `/en/<tag>`, syncs to that prefix, and appends the tag to
124+
`versions.json`.
114125

115126
Deploys need these repository secrets: `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`,
116127
`R2_SECRET_ACCESS_KEY`, `R2_BUCKET`. Until they exist, the deploy step is

‎app/(docs)/[[...slug]]/page.tsx‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,18 @@ import type { Metadata } from 'next';
1313
import { createRelativeLink } from '@/lib/relative-link';
1414
import { gitConfig } from '@/lib/shared';
1515

16+
// Routes carry the .html suffix of the legacy Read the Docs URLs
17+
// (/howto/select.html); page slugs in the source do not.
18+
function toSlugs(slug: string[] | undefined): string[] | undefined {
19+
if (!slug || slug.length === 0) return slug;
20+
const last = slug[slug.length - 1];
21+
if (!last.endsWith('.html')) return slug;
22+
return [...slug.slice(0, -1), last.slice(0, -'.html'.length)];
23+
}
24+
1625
export default async function Page(props: PageProps<'/[[...slug]]'>) {
1726
const params = await props.params;
18-
const page = source.getPage(params.slug);
27+
const page = source.getPage(toSlugs(params.slug));
1928
if (!page) notFound();
2029

2130
const MDX = page.data.body;
@@ -45,12 +54,15 @@ export default async function Page(props: PageProps<'/[[...slug]]'>) {
4554
}
4655

4756
export async function generateStaticParams() {
48-
return source.generateParams();
57+
return source.generateParams().map(({ slug }) => {
58+
if (!slug || slug.length === 0) return { slug };
59+
return { slug: [...slug.slice(0, -1), `${slug[slug.length - 1]}.html`] };
60+
});
4961
}
5062

5163
export async function generateMetadata(props: PageProps<'/[[...slug]]'>): Promise<Metadata> {
5264
const params = await props.params;
53-
const page = source.getPage(params.slug);
65+
const page = source.getPage(toSlugs(params.slug));
5466
if (!page) notFound();
5567

5668
return {

‎app/layout.tsx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,20 @@ const inter = Inter({
77
subsets: ['latin'],
88
});
99

10+
// Resolved (with default) via next.config.mjs `env`.
11+
const basePath = process.env.NEXT_PUBLIC_BASE_PATH;
12+
1013
export const metadata: Metadata = {
11-
metadataBase: new URL(
12-
`https://docs.sqlc.dev${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}`,
13-
),
14+
metadataBase: new URL(`https://docs.sqlc.dev${basePath}`),
1415
title: {
1516
template: '%s — sqlc',
1617
default: 'sqlc Documentation',
1718
},
1819
// Versioned snapshots must never outrank the current docs in search
19-
// engines: only the root (latest) build is indexable.
20-
...(process.env.NEXT_PUBLIC_BASE_PATH
21-
? { robots: { index: false, follow: false } }
22-
: {}),
20+
// engines: only the /en/latest build is indexable.
21+
...(basePath === '/en/latest'
22+
? {}
23+
: { robots: { index: false, follow: false } }),
2324
};
2425

2526
export default function Layout({ children }: LayoutProps<'/'>) {

‎components/version-switcher.tsx‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ interface VersionsFile {
77
versions?: string[];
88
}
99

10-
// The version prefix this build was mounted under ('' for latest).
11-
const CURRENT = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
10+
// The URL prefix this build was mounted under, following the Read the Docs
11+
// scheme: /en/latest for the current docs, /en/vX.Y.Z for snapshots.
12+
// Resolved (with default) via next.config.mjs `env`.
13+
const CURRENT = process.env.NEXT_PUBLIC_BASE_PATH ?? '/en/latest';
14+
const LATEST = '/en/latest';
1215

1316
/**
1417
* Version dropdown driven by /versions.json at the domain root. The file is
@@ -19,7 +22,7 @@ export function VersionSwitcher() {
1922
const [versions, setVersions] = useState<string[]>([]);
2023

2124
useEffect(() => {
22-
// Absolute path on purpose: from a /vX.Y.Z page this still hits the
25+
// Absolute path on purpose: from an /en/vX.Y.Z page this still hits the
2326
// domain root, where versions.json lives.
2427
fetch('/versions.json')
2528
.then((res) => (res.ok ? (res.json() as Promise<VersionsFile>) : null))
@@ -31,7 +34,7 @@ export function VersionSwitcher() {
3134
});
3235
}, []);
3336

34-
if (versions.length === 0 && CURRENT === '') return null;
37+
if (versions.length === 0 && CURRENT === LATEST) return null;
3538

3639
function switchTo(prefix: string) {
3740
const path = window.location.pathname.slice(CURRENT.length);
@@ -45,16 +48,16 @@ export function VersionSwitcher() {
4548
value={CURRENT}
4649
onChange={(e) => switchTo(e.target.value)}
4750
>
48-
<option value="">latest</option>
51+
<option value={LATEST}>latest</option>
4952
{versions.map((v) => (
50-
<option key={v} value={`/${v}`}>
53+
<option key={v} value={`/en/${v}`}>
5154
{v}
5255
</option>
5356
))}
5457
{/* Keep the baked-in version selectable even if versions.json is
5558
unreachable or does not list it. */}
56-
{CURRENT !== '' && !versions.includes(CURRENT.slice(1)) && (
57-
<option value={CURRENT}>{CURRENT.slice(1)}</option>
59+
{CURRENT !== LATEST && !versions.some((v) => `/en/${v}` === CURRENT) && (
60+
<option value={CURRENT}>{CURRENT.replace('/en/', '')}</option>
5861
)}
5962
</select>
6063
);

‎lib/source.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const docs = defineDocs({
1919
// See https://fumadocs.dev/docs/headless/source-api for more info
2020
export const source = loader({
2121
baseUrl: docsRoute,
22+
// Keep the Read the Docs URLs byte-for-byte: pages live at
23+
// <basePath>/howto/select.html, matching what RTD served at
24+
// /en/latest/howto/select.html. The section index stays at /.
25+
url: (slugs) => (slugs.length === 0 ? '/' : `/${slugs.join('/')}.html`),
2226
source: docs.toFumadocsSource(),
2327
plugins: [],
2428
});

‎next.config.mjs‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@ import { createMDX } from 'fumadocs-mdx/next';
22

33
const withMDX = createMDX();
44

5-
// Versioned builds are mounted under a path prefix (e.g. /v1.32.0). Static
6-
// export bakes absolute asset/link/search paths, so the prefix must be set at
7-
// build time. NEXT_PUBLIC_ so client components (version switcher) see it too.
8-
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
5+
// The whole site keeps the Read the Docs URL scheme: the latest build is
6+
// mounted at /en/latest, versioned builds at /en/vX.Y.Z (set
7+
// NEXT_PUBLIC_BASE_PATH). Static export bakes absolute asset/link/search
8+
// paths, so the prefix must be set at build time.
9+
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '/en/latest';
910

1011
/** @type {import('next').NextConfig} */
1112
const config = {
1213
output: 'export',
1314
reactStrictMode: true,
14-
...(basePath === '' ? {} : { basePath }),
15+
basePath,
16+
env: {
17+
// Inline the resolved value (default included) for server and client
18+
// alike — components must never see the raw, possibly-unset variable.
19+
NEXT_PUBLIC_BASE_PATH: basePath,
20+
},
1521
};
1622

1723
export default withMDX(config);

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
"private": true,
55
"scripts": {
66
"ingest": "node scripts/ingest.mjs",
7-
"build": "next build",
8-
"postbuild": "node scripts/redirects.mjs",
7+
"build": "node -e \"require('fs').rmSync('out', {recursive: true, force: true})\" && next build",
8+
"postbuild": "node scripts/fix-html-ext.mjs && node scripts/redirects.mjs",
99
"dev": "next dev",
1010
"start": "serve out",
1111
"types:check": "next typegen && tsc --noEmit"

0 commit comments

Comments
 (0)