Skip to content
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Check out sqlc docs
uses: actions/checkout@v4
with:
repository: sqlc-dev/sqlc
path: .cache/sqlc
sparse-checkout: docs

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci

- name: Ingest
run: node scripts/ingest.mjs --src .cache/sqlc/docs

- name: Build
run: npm run build

- name: Type check
run: npm run types:check

# Downloadable preview of the built site. Serve it locally with e.g.
# `python3 -m http.server` and open /en/latest/.
- uses: actions/upload-artifact@v4
with:
name: site-preview
path: |
out/
out-root/
retention-days: 7
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Dependencies
node_modules/

# Next.js build output
.next/
out/
out-root/
out-segments/

# fumadocs-mdx generated files
.source/
next-env.d.ts

# Ingested content — generated by `npm run ingest` from sqlc-dev/sqlc docs/.
# Never commit it; never hand-edit it.
content/

# Local sqlc checkout used by the ingest script
.cache/

# TypeScript incremental build info
tsconfig.tsbuildinfo
128 changes: 127 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,127 @@
Hi
# sqlc docs site

This repository builds and deploys <https://docs.sqlc.dev>. It contains **no
documentation content**. Content lives in
[sqlc-dev/sqlc](https://github.com/sqlc-dev/sqlc) under `docs/` as plain
GitHub-flavored Markdown; this repo ingests it, renders it with
[Fumadocs](https://fumadocs.dev) on Next.js, and publishes a fully static
site.

```
sqlc-dev/sqlc this repo
┌─────────────────────────┐ ┌──────────────────────────────┐
│ docs/*.md (content) │ checkout │ ingest: md → content tree │
│ docs/toc.yaml (nav) │ ─────────► │ build: Fumadocs / Next.js │
│ internal/docs (Go lint) │ │ deploy: static files → CDN │
└─────────────────────────┘ └──────────────────────────────┘
```

The split exists so that sqlc contributors never need a JavaScript toolchain:
the Go linter in `sqlc/internal/docs` (runs in sqlc's `go test ./...`)
enforces the content contract, and anything that passes it must render here.
If a change to this repo would reject content the linter accepts, that's a
bug in this repo — or a proposed contract change that goes to the linter
first.

## The content contract

Enforced upstream by `sqlc/internal/docs`:

- Plain CommonMark + GFM. No MDX, no JSX, no raw HTML except HTML comments.
- Every page starts with exactly one `#` heading — the page title.
- Admonitions use GitHub alert syntax: `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`.
- Relative links (`../reference/config.md#database`) resolve to real files;
anchors match GitHub-style heading slugs.
- `docs/toc.yaml` lists every page exactly once: an `index`, titled
`sections`, and `unlisted` (published, not in the sidebar).

Two implementation details here are load-bearing for that contract:

- Ingested pages keep their `.md` extension, so fumadocs-mdx compiles them as
**Markdown, not MDX** — literal `{...}` and `<...>` in prose (which the
linter rightly does not reject) render fine.
- `lib/remark-github-alerts.ts` maps `> [!NOTE]` blockquotes to Fumadocs
`<Callout>` components, and `lib/relative-link.tsx` resolves relative `.md`
links (bare ones included) to page routes at render time.

## Local development

```sh
npm ci
npm run ingest # sparse-clones sqlc-dev/sqlc@main into .cache/
npm run dev
```

`npm run ingest -- --ref v1.30.0` builds another ref;
`npm run ingest -- --src ../sqlc/docs` uses a local checkout (CI does this).

The ingest step (`scripts/ingest.mjs`) writes `content/docs/`: it derives
each page's frontmatter `title` from its `#` heading and strips it, copies
the body verbatim, and converts `toc.yaml` into a Fumadocs `meta.json`
(sections become sidebar separators; `unlisted` pages get routes but stay out
of the sidebar). The one page-level transform: `reference/changelog.md` is
split into one page per release (`/en/latest/reference/changelog/v1.31.1.html`)
plus a release index at the original URL. Anything unexpected — an unknown `toc.yaml` field, a page
without a title — fails the build loudly: that's the contract-drift alarm.
`content/` is generated output; never commit or hand-edit it.

`npm run build` emits the static site to `out/`, then the postbuild scripts
align filenames with the `.html` routes (see below) and generate the
domain-root redirect objects in `out-root/`.

## URL scheme

The site keeps the Read the Docs URL scheme byte-for-byte, so existing links
never change or even redirect:

```
/en/latest/howto/select.html the current docs (canonical)
/en/latest/ section index
/en/v1.32.0/howto/select.html versioned snapshots (same scheme RTD used for tags)
/ redirects to /en/latest/
/en/latest/howto/upload.html redirects to /en/latest/howto/push.html
(carried over from the old rediraffe config)
```

Page routes carry the `.html` suffix (a custom `url` in `lib/source.ts`), so
every internal link, search result, and sidebar entry points at the exact
legacy URL and the exported file *is* the page — no edge rewrite rules
needed. Next's export writes `<route>.html.html` plus a per-segment prefetch
directory squatting on the page's own path; `scripts/fix-html-ext.mjs`
renames the former and moves the latter to `out-segments/`, whose keys can
coexist with the page keys in object storage (flat keyspace) but not on a
filesystem. Hosts serving `out/` alone (local preview, GitHub Pages) still
work: the segment prefetch probes 404 and the client falls back to the
full-page `.txt` payload.

## Versioning

Versions are immutable build artifacts, not branches:

- `/en/latest` serves the current docs, rebuilt on every `main` docs change.
- A release build uses `NEXT_PUBLIC_BASE_PATH=/en/v1.32.0`
(static export bakes absolute asset/link/search paths, so the prefix is a
build-time setting) and is uploaded to the `en/v1.32.0/` prefix, once,
forever.
- `versions.json` at the domain root, appended by each release, drives the
version-switcher dropdown; every snapshot fetches it at runtime so old
snapshots list new versions.
- Versioned builds set `noindex` so stale versions never outrank current
docs in search engines.
- Versioning starts at the first tag that contains `docs/toc.yaml`; older
tags are not backfilled. Old RTD tag URLs for those (`/en/v1.29.0/...`)
can redirect to `/en/latest/` at the edge.

## CI

`ci.yml` runs ingest + build + typecheck on every PR and push to main, and
uploads the built site as a `site-preview` artifact (serve it locally with
`python3 -m http.server` and open `/en/latest/`).

Deployment is not wired up yet — hosting is still to be decided. When it
is, the deploy needs to upload `out/` and `out-segments/` into the version
prefix (`en/latest/` or `en/<tag>/`) and the `out-root/` redirect objects to
the domain root, and a release deploy appends its tag to `versions.json`.
The trigger side is a `repository_dispatch` from a small workflow in
sqlc-dev/sqlc on pushes to `main` that touch `docs/**` (plus a tag-push
equivalent), with a daily cron here as a safety net.
80 changes: 80 additions & 0 deletions app/(docs)/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { getPageImageUrl, getPageMarkdownUrl, source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
MarkdownCopyButton,
ViewOptionsPopover,
} from 'fumadocs-ui/layouts/docs/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/components/mdx';
import type { Metadata } from 'next';
import { createRelativeLink } from '@/lib/relative-link';
import { gitConfig } from '@/lib/shared';

// Routes carry the .html suffix of the legacy Read the Docs URLs
// (/howto/select.html); page slugs in the source do not.
function toSlugs(slug: string[] | undefined): string[] | undefined {
if (!slug || slug.length === 0) return slug;
const last = slug[slug.length - 1];
if (!last.endsWith('.html')) return slug;
return [...slug.slice(0, -1), last.slice(0, -'.html'.length)];
}

export default async function Page(props: PageProps<'/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(toSlugs(params.slug));
if (!page) notFound();

const MDX = page.data.body;
const markdownUrl = getPageMarkdownUrl(page).url;
// Per-release changelog pages are split out of reference/changelog.md by
// the ingest step; their upstream source is that one file.
const sourcePath = page.path.startsWith('reference/changelog/')
? 'reference/changelog.md'
: page.path;

return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b pb-6">
<MarkdownCopyButton markdownUrl={markdownUrl} />
<ViewOptionsPopover
markdownUrl={markdownUrl}
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/docs/${sourcePath}`}
/>
</div>
<DocsBody>
<MDX
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}

export async function generateStaticParams() {
return source.generateParams().map(({ slug }) => {
if (!slug || slug.length === 0) return { slug };
return { slug: [...slug.slice(0, -1), `${slug[slug.length - 1]}.html`] };
});
}

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

return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: getPageImageUrl(page).url,
},
};
}
16 changes: 16 additions & 0 deletions app/(docs)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import { VersionSwitcher } from '@/components/version-switcher';

export default function Layout({ children }: LayoutProps<'/'>) {
return (
<DocsLayout
tree={source.getPageTree()}
sidebar={{ banner: <VersionSwitcher /> }}
{...baseOptions()}
>
{children}
</DocsLayout>
);
}
9 changes: 9 additions & 0 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';

export const revalidate = false;

export const { staticGET: GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
});
12 changes: 12 additions & 0 deletions app/global.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';

html {
scrollbar-gutter: stable;
}

html > body[data-scroll-locked] {
margin-right: 0px !important;
--removed-body-scroll-bar-size: 0px !important;
}
34 changes: 34 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Inter } from 'next/font/google';
import { Provider } from '@/components/provider';
import type { Metadata } from 'next';
import './global.css';

const inter = Inter({
subsets: ['latin'],
});

// Resolved (with default) via next.config.mjs `env`.
const basePath = process.env.NEXT_PUBLIC_BASE_PATH;

export const metadata: Metadata = {
metadataBase: new URL(`https://docs.sqlc.dev${basePath}`),
title: {
template: '%s — sqlc',
default: 'sqlc Documentation',
},
// Versioned snapshots must never outrank the current docs in search
// engines: only the /en/latest build is indexable.
...(basePath === '/en/latest'
? {}
: { robots: { index: false, follow: false } }),
};

export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<Provider>{children}</Provider>
</body>
</html>
);
}
10 changes: 10 additions & 0 deletions app/llms-full.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { getLLMText, source } from '@/lib/source';

export const revalidate = false;

export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);

return new Response(scanned.join('\n\n'));
}
23 changes: 23 additions & 0 deletions app/llms.mdx/docs/[[...slug]]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
import { notFound } from 'next/navigation';

export const revalidate = false;

export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
const { slug } = await params;
// remove the appended "content.md"
const page = source.getPage(slug?.slice(0, -1));
if (!page) notFound();

return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}

export function generateStaticParams() {
return source.getPages().map((page) => ({
slug: getPageMarkdownUrl(page).segments,
}));
}
8 changes: 8 additions & 0 deletions app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { source } from '@/lib/source';
import { llms } from 'fumadocs-core/source';

export const revalidate = false;

export function GET() {
return new Response(llms(source).index());
}
Loading
Loading