diff --git a/apps/fumadocs/AGENTS.md b/apps/fumadocs/AGENTS.md index 1a18b766..88d27a87 100644 --- a/apps/fumadocs/AGENTS.md +++ b/apps/fumadocs/AGENTS.md @@ -49,7 +49,7 @@ Every page is served under a version segment: `/docs/next/quickstart`, `/docs/1. - **A repository link names the branch, and the snapshot pins it.** Pages write `https://github.com/suiramdev/freenary/blob/main/…`; the snapshot rewrites each one to `blob/v/` in the copy, because that link cannot resolve at render time. `docs:check` fails a `blob/main` link inside a frozen version. - **`/docs/*` with no version redirects** (307) to the newest release. `versionMiddleware` in `src/app/start.ts` owns it, and it runs before `llmMiddleware`. The newest release is `stableVersion()` in `src/shared/content/source.ts`: the highest `X.Y` folder, or `next` while no release exists. - **The language rules apply to `next` alone**; the structure rules apply to every version. `check-docs.ts` skips `checkHeadings`, `checkPhrases` and `checkSentences` outside `next`. -- **A release needs its snapshot first.** A maintainer runs `bun run docs:snapshot X.Y.Z` in a pull request to `dev`; the `tag` job of `.github/workflows/release.yml` refuses a stable version whose `content/docs/X.Y` folder is missing (the `main and dev` ruleset admits no workflow push). The script derives the `X.Y` folder from the release version, pins repository links to `v` and pins `FREENARY_VERSION=main` lines to `X.Y`; `docs:check` fails a frozen page that still carries either moving form. It stages the copy outside `content/docs` and rewrites the root `meta.json` on every run, so a repeat run finishes an interrupted one; a pre-release snapshots nothing. +- **A release needs its snapshot first, and it snapshots last.** A maintainer runs `bun run docs:snapshot X.Y.Z` in a pull request to `dev`; the `tag` job of `.github/workflows/release.yml` refuses a stable version whose `content/docs/X.Y` folder is missing (the `main and dev` ruleset admits no workflow push). The folder freezes at that merge, so a documentation change merged between it and the release reaches `next` alone and no gate reports it. The script derives the `X.Y` folder from the release version and pins the three moving forms `scripts/moving-refs.ts` holds: a repository link and a `raw.githubusercontent.com` download URL take `v`, and a `FREENARY_VERSION=main` line takes `X.Y`. `docs:check` reads the same table, so it fails a frozen page that still carries any of them. It stages the copy outside `content/docs` and rewrites the root `meta.json` on every run, so a repeat run finishes an interrupted one; a pre-release snapshots nothing. - **Search, the AI panel and the LLM endpoints are scoped.** `/api/search` tags each record with its version and the default dialog passes `defaultTag` — the version of the pathname, or the newest release off the docs tree, from the root loader in `src/app/routes/__root.tsx`. `/api/chat` builds one index per version and honours the request body's version only when `listVersions()` holds it. `/llms.txt` and `/llms-full.txt` serve the newest release alone. ## Content structure diff --git a/apps/fumadocs/content/docs/0.1/quickstart.mdx b/apps/fumadocs/content/docs/0.1/quickstart.mdx index af852aa4..80d6c284 100644 --- a/apps/fumadocs/content/docs/0.1/quickstart.mdx +++ b/apps/fumadocs/content/docs/0.1/quickstart.mdx @@ -20,8 +20,8 @@ The stack runs published images, so it needs no source code. Two files are enoug ```bash mkdir freenary && cd freenary -curl -O https://raw.githubusercontent.com/suiramdev/freenary/main/docker-compose.yml -curl -O https://raw.githubusercontent.com/suiramdev/freenary/main/.env.example +curl -O https://raw.githubusercontent.com/suiramdev/freenary/v0.1.0/docker-compose.yml +curl -O https://raw.githubusercontent.com/suiramdev/freenary/v0.1.0/.env.example ``` Every command below runs in that directory. diff --git a/apps/fumadocs/content/docs/0.1/self-hosting/index.mdx b/apps/fumadocs/content/docs/0.1/self-hosting/index.mdx index b502c8e9..2ad9c9f5 100644 --- a/apps/fumadocs/content/docs/0.1/self-hosting/index.mdx +++ b/apps/fumadocs/content/docs/0.1/self-hosting/index.mdx @@ -56,7 +56,7 @@ Two more values name the public origins. Both default to `localhost`, which serv Compose gives the `web` service `PUBLIC_SERVER_URL` from `BETTER_AUTH_URL`, so the browser calls the same API origin. A `PUBLIC_SERVER_URL` line of your own does nothing. -`FREENARY_VERSION` names the tag of both images. The compose default is `latest`, and the registry holds no tag of that name, so write a real tag. +`FREENARY_VERSION` names the tag of both images. The compose default is `latest`, the newest release, and it moves at each one. Name a version such as `0.1` to hold an instance on one release line. [Configuration](/docs/self-hosting/configuration) holds every variable, its default and its refusal message. @@ -72,8 +72,8 @@ The stack runs published images, so it needs no source code. Two files are enoug ```bash mkdir freenary && cd freenary -curl -O https://raw.githubusercontent.com/suiramdev/freenary/main/docker-compose.yml -curl -O https://raw.githubusercontent.com/suiramdev/freenary/main/.env.example +curl -O https://raw.githubusercontent.com/suiramdev/freenary/v0.1.0/docker-compose.yml +curl -O https://raw.githubusercontent.com/suiramdev/freenary/v0.1.0/.env.example ``` A clone gives the same two files, plus the source for a [build from source](/docs/self-hosting/from-source). Every command below runs from the directory that holds the two files. diff --git a/apps/fumadocs/content/docs/next/contributing/releasing.mdx b/apps/fumadocs/content/docs/next/contributing/releasing.mdx index 1a431e30..f3ea0488 100644 --- a/apps/fumadocs/content/docs/next/contributing/releasing.mdx +++ b/apps/fumadocs/content/docs/next/contributing/releasing.mdx @@ -74,7 +74,7 @@ A maintainer runs it by hand: ### Snapshot the documentation -On a branch from `dev`, run `cd apps/fumadocs && bun run docs:snapshot X.Y.Z`, then open a pull request against `dev` and merge it. A pre-release skips this step. +On a branch from `dev`, run `cd apps/fumadocs && bun run docs:snapshot X.Y.Z`, then open a pull request against `dev` and merge it. Make it the last change before the release: a documentation change merged after it reaches `next` alone. A pre-release skips this step, and a patch release finds its folder in place and copies nothing. @@ -140,7 +140,9 @@ The documentation site publishes one copy of the pages for each version. `conten The snapshot lands through a pull request into `dev`. The `tag` job refuses a stable version whose folder is missing. The `main and dev` ruleset admits no push outside a pull request, so the workflow writes no commit. -The copy carries two edits. A repository link names `main`, and the frozen page names the release tag. A `FREENARY_VERSION=main` line names the branch, and the frozen page names `X.Y`. Two rules follow: +The folder therefore freezes when that pull request merges, and the release comes later. A documentation change merged in between reaches `next` and never reaches the frozen folder, and no gate reports it. Snapshot last, and the window stays empty. + +The copy pins every moving reference. A repository link names `main`, and the frozen page names the release tag. A download URL names `main`, and the frozen page names the tag. A `FREENARY_VERSION=main` line names the branch, and the frozen page names `X.Y`. Two rules follow: - A patch release finds `content/docs/X.Y` in place, and copies nothing. A documentation fix for a released version therefore edits that folder directly. - A pre-release copies nothing. Its readers stay on `next`. diff --git a/apps/fumadocs/content/docs/next/contributing/writing-docs.mdx b/apps/fumadocs/content/docs/next/contributing/writing-docs.mdx index 61154188..769753e9 100644 --- a/apps/fumadocs/content/docs/next/contributing/writing-docs.mdx +++ b/apps/fumadocs/content/docs/next/contributing/writing-docs.mdx @@ -62,7 +62,7 @@ To freeze a version by hand: cd apps/fumadocs && bun run docs:snapshot 1.2.0 ``` -The script takes the release version and freezes it as the `1.2` folder. It refuses anything else, and it leaves the pages alone when the folder exists. It labels the copy and adds it to the root `meta.json`, which orders the version dropdown. It also pins each repository link in the copy to the release tag. It also pins each `FREENARY_VERSION=main` line in the copy to `X.Y`. +The script takes the release version and freezes it as the `1.2` folder. It refuses anything else, and it leaves the pages alone when the folder exists. It labels the copy and adds it to the root `meta.json`, which orders the version dropdown. It also pins every moving reference in the copy. A repository link and a download URL take the release tag, and a `FREENARY_VERSION=main` line takes `1.2`. `docs:check` fails a frozen page that still holds one of the three moving forms. ## The five steps @@ -243,6 +243,8 @@ The array also accepts four special entries: `"---Separator---"`, `"..."`, `"!sl | A link that names a version | Passes | Fails | | A version folder with no `"root": true`, or a stale title | Passes, drops out of the dropdown | Fails | | A `blob/main` repository link inside a frozen version | Passes | Fails | +| A `raw.githubusercontent.com` download URL that names `main`, inside a frozen version | Passes | Fails | +| A `FREENARY_VERSION=main` line inside a frozen version | Passes | Fails | | A command or a variable inside `guides/` | Passes | Fails | | A contraction, a banned word, a plan | Passes | Fails | | A sentence over 25 words | Passes | Fails | diff --git a/apps/fumadocs/content/docs/next/self-hosting/index.mdx b/apps/fumadocs/content/docs/next/self-hosting/index.mdx index e5c671c9..155d313c 100644 --- a/apps/fumadocs/content/docs/next/self-hosting/index.mdx +++ b/apps/fumadocs/content/docs/next/self-hosting/index.mdx @@ -56,7 +56,7 @@ Two more values name the public origins. Both default to `localhost`, which serv Compose gives the `web` service `PUBLIC_SERVER_URL` from `BETTER_AUTH_URL`, so the browser calls the same API origin. A `PUBLIC_SERVER_URL` line of your own does nothing. -`FREENARY_VERSION` names the tag of both images. The compose default is `latest`, and the registry holds no tag of that name, so write a real tag. +`FREENARY_VERSION` names the tag of both images. The compose default is `latest`, the newest release, and it moves at each one. Name a version such as `X.Y` to hold an instance on one release line. [Configuration](/docs/self-hosting/configuration) holds every variable, its default and its refusal message. diff --git a/apps/fumadocs/scripts/check-docs.ts b/apps/fumadocs/scripts/check-docs.ts index 2f4ec9d0..b4e7d9ef 100755 --- a/apps/fumadocs/scripts/check-docs.ts +++ b/apps/fumadocs/scripts/check-docs.ts @@ -4,7 +4,6 @@ import { join, relative } from "node:path"; import { icons } from "lucide-react"; -import { gitConfig, repoBlobUrl } from "../src/shared/config/site"; import { isVersionId, NEXT_VERSION } from "../src/shared/lib/versions"; import { getMDXComponents } from "../src/shared/ui/mdx"; import { @@ -28,6 +27,7 @@ import { STE_PARAGRAPH_SENTENCE_LIMIT, STE_WORD_SUBSTITUTIONS, } from "./docs-rules"; +import { indexOfMatchedText, MOVING_REFS } from "./moving-refs"; type Severity = "error" | "warning"; @@ -296,43 +296,23 @@ const checkLinks = ( } }; -const checkRepoLinksArePinned = (page: DocPage, report: Report) => { - const movingBranchLinkTarget = `](${repoBlobUrl(gitConfig.branch)}`; - let index = page.raw.indexOf(movingBranchLinkTarget); +const checkMovingRefsArePinned = (page: DocPage, report: Report) => { + const folder = versionOf(page.relativePath); - while (index !== -1) { - report( - "error", - page.relativePath, - lineAt(page.raw, index), - "link", - `a repository link names \`${gitConfig.branch}\`. A released page pins it: \`/blob/v${versionOf(page.relativePath)}.0/\`, or whichever patch tag holds the code the page describes.` - ); - - index = page.raw.indexOf( - movingBranchLinkTarget, - index + movingBranchLinkTarget.length - ); - } -}; + for (const ref of MOVING_REFS) { + let index = page.raw.indexOf(ref.moving); -const checkVersionExamplesArePinned = (page: DocPage, report: Report) => { - const movingVersionExampleLine = "\nFREENARY_VERSION=main\n"; - let index = page.raw.indexOf(movingVersionExampleLine); - - while (index !== -1) { - report( - "error", - page.relativePath, - lineAt(page.raw, index + 1), - "version", - `\`FREENARY_VERSION=main\` names the moving branch. A released page pins it: \`FREENARY_VERSION=${versionOf(page.relativePath)}\`.` - ); + while (index !== -1) { + report( + "error", + page.relativePath, + lineAt(page.raw, indexOfMatchedText(ref, index)), + ref.rule, + ref.message(folder) + ); - index = page.raw.indexOf( - movingVersionExampleLine, - index + movingVersionExampleLine.length - ); + index = page.raw.indexOf(ref.moving, index + ref.moving.length); + } } }; @@ -710,8 +690,7 @@ for (const page of pages) { if (versionOf(page.relativePath) === NEXT_VERSION) { checkAuthoredLanguage(page, report); } else { - checkRepoLinksArePinned(page, report); - checkVersionExamplesArePinned(page, report); + checkMovingRefsArePinned(page, report); } } diff --git a/apps/fumadocs/scripts/moving-refs.ts b/apps/fumadocs/scripts/moving-refs.ts new file mode 100644 index 00000000..30961698 --- /dev/null +++ b/apps/fumadocs/scripts/moving-refs.ts @@ -0,0 +1,50 @@ +import { gitConfig, repoBlobUrl, repoRawUrl } from "../src/shared/config/site"; + +export type ReleasePin = { + readonly folder: string; + readonly tag: string; +}; + +export type MovingRef = { + readonly message: (folder: string) => string; + readonly moving: string; + readonly pinned: (pin: ReleasePin) => string; + readonly rule: "link" | "version"; +}; + +export const MOVING_REFS: readonly MovingRef[] = [ + { + message: (folder) => + `a repository link names \`${gitConfig.branch}\`. A released page pins it: \`/blob/v${folder}.0/\`, or whichever patch tag holds the code the page describes.`, + moving: `](${repoBlobUrl(gitConfig.branch)}`, + pinned: ({ tag }) => `](${repoBlobUrl(tag)}`, + rule: "link", + }, + { + message: (folder) => + `a download URL names \`${gitConfig.branch}\`, so the reader gets a file that moved past this release. A released page pins it: \`/v${folder}.0/\`, or whichever patch tag holds the files the page describes.`, + moving: repoRawUrl(gitConfig.branch), + pinned: ({ tag }) => repoRawUrl(tag), + rule: "link", + }, + { + message: (folder) => + `\`FREENARY_VERSION=${gitConfig.branch}\` names the moving branch. A released page pins it: \`FREENARY_VERSION=${folder}\`.`, + moving: `\nFREENARY_VERSION=${gitConfig.branch}\n`, + pinned: ({ folder }) => `\nFREENARY_VERSION=${folder}\n`, + rule: "version", + }, +]; + +export const pinMovingRefs = (raw: string, pin: ReleasePin) => { + let pinned = raw; + + for (const ref of MOVING_REFS) { + pinned = pinned.replaceAll(ref.moving, ref.pinned(pin)); + } + + return pinned; +}; + +export const indexOfMatchedText = (ref: MovingRef, index: number) => + ref.moving.startsWith("\n") ? index + 1 : index; diff --git a/apps/fumadocs/scripts/snapshot-version.ts b/apps/fumadocs/scripts/snapshot-version.ts index 35d309ce..0afc5615 100755 --- a/apps/fumadocs/scripts/snapshot-version.ts +++ b/apps/fumadocs/scripts/snapshot-version.ts @@ -2,12 +2,12 @@ import { cp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { gitConfig, repoBlobUrl } from "../src/shared/config/site"; import { compareVersionIds, NEXT_VERSION, releaseFolder, } from "../src/shared/lib/versions"; +import { pinMovingRefs, type ReleasePin } from "./moving-refs"; type NavMeta = { pages?: string[]; @@ -18,7 +18,6 @@ const CONTENT_DIR = "content/docs"; const STAGING_DIR_OUTSIDE_DOCS = "content/.snapshot"; const VERSION_LIST_FILE = "meta.json"; const PAGE_EXTENSION = ".mdx"; -const MOVING_VERSION_EXAMPLE_LINE = "\nFREENARY_VERSION=main\n"; const exists = async (path: string) => { const parent = path.slice(0, path.lastIndexOf("/")); @@ -54,33 +53,13 @@ const mdxFilesUnder = async (dir: string): Promise => { return found; }; -const pinRepoLinksToTag = async (dir: string, tag: string) => { - const movingBranchLinkTarget = `](${repoBlobUrl(gitConfig.branch)}`; - const releaseTagLinkTarget = `](${repoBlobUrl(tag)}`; - - for (const page of await mdxFilesUnder(dir)) { - const raw = await readFile(page, "utf8"); - - if (raw.includes(movingBranchLinkTarget)) { - await writeFile( - page, - raw.replaceAll(movingBranchLinkTarget, releaseTagLinkTarget) - ); - } - } -}; - -const pinVersionExamplesToRelease = async (dir: string, release: string) => { - const releaseVersionExampleLine = `\nFREENARY_VERSION=${release}\n`; - +const pinMovingRefsUnder = async (dir: string, pin: ReleasePin) => { for (const page of await mdxFilesUnder(dir)) { const raw = await readFile(page, "utf8"); + const pinned = pinMovingRefs(raw, pin); - if (raw.includes(MOVING_VERSION_EXAMPLE_LINE)) { - await writeFile( - page, - raw.replaceAll(MOVING_VERSION_EXAMPLE_LINE, releaseVersionExampleLine) - ); + if (pinned !== raw) { + await writeFile(page, pinned); } } }; @@ -109,8 +88,10 @@ if (await exists(target)) { await rm(STAGING_DIR_OUTSIDE_DOCS, { force: true, recursive: true }); await cp(authored, STAGING_DIR_OUTSIDE_DOCS, { recursive: true }); - await pinRepoLinksToTag(STAGING_DIR_OUTSIDE_DOCS, `v${version}`); - await pinVersionExamplesToRelease(STAGING_DIR_OUTSIDE_DOCS, folder); + await pinMovingRefsUnder(STAGING_DIR_OUTSIDE_DOCS, { + folder, + tag: `v${version}`, + }); const stagedVersionList = join(STAGING_DIR_OUTSIDE_DOCS, VERSION_LIST_FILE); const stagedMeta = await readNavMeta(stagedVersionList); @@ -119,7 +100,7 @@ if (await exists(target)) { await rename(STAGING_DIR_OUTSIDE_DOCS, target); console.log( - `Wrote ${target}, with repository links pinned to v${version} and FREENARY_VERSION pinned to ${folder}.` + `Wrote ${target}, with every moving reference pinned to v${version} and FREENARY_VERSION pinned to ${folder}.` ); } diff --git a/apps/fumadocs/src/shared/config/site.ts b/apps/fumadocs/src/shared/config/site.ts index a52ff859..29a73652 100644 --- a/apps/fumadocs/src/shared/config/site.ts +++ b/apps/fumadocs/src/shared/config/site.ts @@ -10,3 +10,6 @@ export const gitConfig = { export const repoBlobUrl = (ref: string) => `https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${ref}/`; + +export const repoRawUrl = (ref: string) => + `https://raw.githubusercontent.com/${gitConfig.user}/${gitConfig.repo}/${ref}/`; diff --git a/docs/engineering/platform.md b/docs/engineering/platform.md index 2ad83ac7..541b200f 100644 --- a/docs/engineering/platform.md +++ b/docs/engineering/platform.md @@ -71,10 +71,10 @@ Durable facts that the code cannot carry in a name. Each bullet names the module - `.github/workflows/release.yml › publish` — with no earlier `v*` tag, GitHub's generated notes start at the previous _release_, which is a `data-` merchant-data release, so the first version lists its merged pull requests itself. `pull-requests: read` in the top-level `permissions` exists for that one `gh pr list` call, because naming any scope sets the rest to `none`. - `apps/fumadocs/scripts/snapshot-version.ts › STAGING_DIR_OUTSIDE_DOCS` — the copy is staged outside `content/docs` and moved in with one `rename`, so an interrupted run leaves no half-written version for the site to serve, and no folder a retry would mistake for a finished snapshot. - `apps/fumadocs/scripts/snapshot-version.ts › module` — the root `meta.json` version-list rewrite sits outside the `exists(target)` branch on purpose: a run that copied the folder and then stopped left the release out of the version list, and the retry has to finish that half rather than report success. -- `apps/fumadocs/scripts/snapshot-version.ts › pinRepoLinksToTag` — only the `](` link-target form is rewritten. The same URL prefix appears in prose, where the authoring page names the branch on purpose, and a link into another repository's branch is nobody's to pin. -- `apps/fumadocs/scripts/snapshot-version.ts › pinVersionExamplesToRelease` — only the whole-line `FREENARY_VERSION=main` form inside a fence is rewritten, for the same reason as the link pin: `contributing/releasing.mdx` and `contributing/writing-docs.mdx` name that literal inline, in a sentence that describes the rewrite itself. `checkVersionExamplesArePinned` in `check-docs.ts` matches the same line form, so the gate and the pin cannot disagree. +- `apps/fumadocs/scripts/moving-refs.ts › MOVING_REFS` — one table names every reference a frozen page has to pin, and both the writer (`snapshot-version.ts`) and the gate (`check-docs.ts`) read it, so they cannot disagree about which forms exist. Two entries are anchored to the syntax that carries them, because an authoring page names the moving form in prose on purpose: `](` and not the URL a sentence quotes, which `contributing/writing-docs.mdx` does where it explains this rewrite; the whole-line `\nFREENARY_VERSION=main\n` and not the inline mention, which `contributing/releasing.mdx` and `contributing/writing-docs.mdx` both carry. The raw download URL is the bare string and therefore matches in prose too, because it exists only as the target of a `curl` in an install fence; a page that names that prefix in a sentence gets rewritten by the writer and flagged by the gate, so name the bare host instead. Every moving form is built from `gitConfig.branch`, so renaming the branch moves the pin and the gate together. +- `apps/fumadocs/scripts/moving-refs.ts › indexOfMatchedText` — a moving form anchored to the start of a line begins at the newline that ends the line before it, so the reported line is one past the match. Without this the `FREENARY_VERSION` finding names the fence opener instead of the offending line. - `apps/fumadocs/scripts/check-docs.ts › RULE_DISABLE_DIRECTIVE` — a page switches rules off for itself by writing `{/* docs-check disable: ste-word, no-roadmap */}`. One page uses it: `content/docs/next/contributing/writing-docs.mdx` has to name the words the language rules ban. -- `apps/fumadocs/scripts/check-docs.ts › checkAuthoredLanguage` — the language rules run on `next` alone, because a frozen version passed them when it was written and the word lists keep moving. A frozen page gets `checkRepoLinksArePinned` and `checkVersionExamplesArePinned` in their place; its structure rules still apply. +- `apps/fumadocs/scripts/check-docs.ts › checkAuthoredLanguage` — the language rules run on `next` alone, because a frozen version passed them when it was written and the word lists keep moving. A frozen page gets `checkMovingRefsArePinned` in their place; its structure rules still apply. - `apps/fumadocs/scripts/docs-model.ts › maskNonProse` — every mask replaces characters one for one and keeps the newlines, so a byte offset in the masked text still maps to the line it came from; `lineAt` over `maskedProse` depends on that invariant. `maskAsCapitalisedWord` masks inline code to a run of `X` rather than spaces because inline code is one word to a reader and can open a sentence: the sentence splitter needs the capital and the word counter needs the token. - `apps/fumadocs/scripts/docs-rules.ts › module` — every table here restates a rule `apps/fumadocs/AGENTS.md` already states in prose and the build does not catch. The two have to be kept in step.