Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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