diff --git a/.cursor/rules/librechat-documentation.mdc b/.cursor/rules/librechat-documentation.mdc deleted file mode 100644 index 4531a4fc6..000000000 --- a/.cursor/rules/librechat-documentation.mdc +++ /dev/null @@ -1,146 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# LibreChat Documentation Rules - -## Config Version Updates - -When updating the LibreChat config version (e.g., from v1.2.6 to v1.2.7), follow these steps: - -### 1. Create Changelog Files - -#### Main Changelog File -Create: `pages/changelog/config_v{VERSION}.mdx` - -Template: -```mdx ---- -date: YYYY/MM/DD -title: ⚙️ Config v{VERSION} ---- - -import { ChangelogHeader } from '@/components/changelog/ChangelogHeader' -import Content from '@/components/changelog/content/config_v{VERSION}.mdx' - - - ---- - - -``` - -#### Content File -Create: `components/changelog/content/config_v{VERSION}.mdx` - -Format: -- Use bullet points starting with `-` -- Group related changes together -- Include links to detailed documentation using `[Feature Name](/docs/configuration/librechat_yaml/object_structure/{feature})` -- Describe what was added/changed and its purpose -- Keep descriptions concise but informative - -Example: -```mdx -- Added `memory` configuration to control memory functionality for conversations - - Configure memory persistence and personalization settings - - Set token limits and message window sizes for memory context - - Configure agents for memory processing with provider-specific settings - - Supports both predefined agents (by ID) and custom agent configurations - - See [Memory Configuration](/docs/configuration/librechat_yaml/object_structure/memory) for details -``` - -### 2. Create Object Structure Documentation - -For new root-level configurations, create: `pages/docs/configuration/librechat_yaml/object_structure/{feature}.mdx` - -Structure: -1. **Title**: `# {Feature} Configuration` -2. **Overview**: Brief description of the feature -3. **Example**: Complete YAML example showing all options -4. **Field Documentation**: Use `` components for each field -5. **Subsections**: For complex nested objects -6. **Notes**: Important considerations at the end - -### 3. Update Navigation - -Add the new feature to: `pages/docs/configuration/librechat_yaml/object_structure/_meta.ts` - -Insert alphabetically or logically within the structure: -```ts -export default { - config: 'Root Settings', - file_config: 'File Config', - interface: 'Interface (UI)', - // ... other entries - memory: 'Memory', // Add new entry - // ... remaining entries -} -``` - -### 4. Update Main Config Documentation - -In `pages/docs/configuration/librechat_yaml/object_structure/config.mdx`: - -1. Update the version example: - ```yaml - ['version', 'String', 'Specifies the version of the configuration file.', 'version: 1.2.8' ], - ``` - -2. Add the new configuration section (insert alphabetically or logically): - ```mdx - ## memory - - **Key:** - - - **Subkeys:** - - - see: [Memory Object Structure](/docs/configuration/librechat_yaml/object_structure/memory) - ``` - -## Documentation Standards - -### OptionTable Usage -```mdx - -``` - -### YAML Examples -- Use `filename` attribute for code blocks: ` ```yaml filename="memory" ` -- Show realistic, working examples -- Include comments only when necessary for clarity - -### Field Descriptions -- Be precise about default values -- Explain the impact of different settings -- Note any relationships between fields -- Mention when fields are required vs optional - -### Special Considerations -- For boolean fields that give users control, clarify WHO gets the control (admin vs end-user) -- For fields that replace default behavior, explicitly state this -- For union types, show examples of each variant -- For nested objects, create subsections with their own OptionTables - -## Version Numbering -- Config versions follow semantic versioning: v{MAJOR}.{MINOR}.{PATCH} -- Adding new root-level configurations typically warrants a minor version bump -- Breaking changes require a major version bump -- Bug fixes or minor adjustments use patch versions \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcae7e1c3..ea691acd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: - name: Check formatting run: pnpm lint:prettier + - name: Check config versions + run: pnpm check:config-version + - name: Typecheck run: pnpm typecheck diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 028a6a680..c4ebf7ea0 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -34,19 +34,21 @@ jobs: # Root-relative links (/docs, /images, ...) are app routes/assets that do # not map to local files (content lives under content/ and routes are - # extensionless). --root-dir lets lychee resolve them to - # $GITHUB_WORKSPACE/ instead of erroring with "provide a root dir"; - # we then exclude them, since they can't be checked on disk. The exclude - # is anchored at the regex-escaped workspace so it does NOT also skip - # file-relative links (./LICENSE, ../../features/foo.md), which - # legitimately resolve under $GITHUB_WORKSPACE/content/ and stay checked. + # extensionless). Archived docs routes (/vX.Y[.Z|.x][-rcN]/docs/...) likewise + # have no checkout path at the repository root. --root-dir lets lychee + # resolve them to $GITHUB_WORKSPACE/ instead of erroring with + # "provide a root dir"; we then exclude them, since they can't be checked + # on disk. The exclude is anchored at the regex-escaped workspace so it + # does NOT also skip file-relative links (./LICENSE, ../../features/foo.md), + # which legitimately resolve under $GITHUB_WORKSPACE/content/ and stay + # checked. - name: Build internal-route exclude pattern id: routes env: WS: ${{ github.workspace }} run: | esc=$(printf '%s' "$WS" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') - echo "pattern=^file://${esc}/(docs|blog|changelog|images|videos|assets|toolkit)(/|\$)" >> "$GITHUB_OUTPUT" + echo "pattern=^file://${esc}/(docs|blog|changelog|images|videos|assets|toolkit|v[0-9]+\.[0-9]+(\.[0-9]+|\.x)?(-rc[0-9]*)?/docs)(/|\$)" >> "$GITHUB_OUTPUT" # scripts/translate.ts generates content/docs/**/*..mdx from the English # source. Each translated copy's in-page anchors still point at the English diff --git a/.prettierignore b/.prettierignore index 2bc74b699..03ac9ddcb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,3 +14,7 @@ public/starters-hashes.json # and the translation memory. English meta.json is still formatted. content/docs/**/meta.*.json content/.i18n-cache/ + +# Frozen docs snapshots: byte-for-byte copies of a released version's content +# (see content/docs-archive/README.md). Reformatting them would rewrite history. +content/docs-archive/ diff --git a/README.md b/README.md index 52aa5e44d..5ad0c09da 100644 --- a/README.md +++ b/README.md @@ -171,23 +171,68 @@ Only pages listed in the `pages` array appear in the sidebar, in the order given **Localization:** English (`.mdx`) is the source of truth. Translated pages use a locale suffix (for example `index.es.mdx`), and each locale's search index only includes pages that have a real translated file. Keep new content in English and let the translation workflow handle the rest. +## Docs Versions + +`content/docs/` is the live version, served at `/docs` and labelled by `CURRENT_VERSION` in +`lib/versions.ts`. Every published release and release candidate is a frozen snapshot under +`content/docs-archive//`, served at `//docs` — for example +`/v0.8.5/docs/quick_start`. Version ids match `v.[.|.x][-rc]`; the +switcher orders them newest-first, with a release ranked above its own candidates. + +Publish one with: + +```bash +pnpm docs:archive v0.8.5 --ref= +``` + +That copies the English docs out of git at `--ref` (default `HEAD`), drops every localized file, +and rewrites absolute `/docs/...` links to `//docs/...` so an archived page never links +back into the live docs. The sidebar version switcher is built from the directories that exist, so +no code change is needed to publish or retire a version. + +Archived snapshots are deliberately inert: English-only, `noindex`, absent from the sitemap, +search index, `llms.txt`, the translation workflow and `pnpm sync:config-version` (they keep the +`librechat.yaml` version they shipped with). + +Two limits are worth knowing: + +- **The archive starts at v0.8.2.** This repository only gained `content/docs` in the Fumadocs + migration, and every release up to v0.8.2 had its changelog backfilled in that single commit, so + there is exactly one docs tree for all of them — published once as `v0.8.2`. Docs for v0.5.x–v0.8.1 + exist only as the pre-migration Nextra `pages/` tree and would need converting before they could + be archived. +- **Snapshots are bundled, so they cost build memory.** Archived pages inherit real MDX imports + (`next/image`, `@/components/...`) from the docs they snapshot, which on-demand compilation + cannot resolve, so the collection is bundled like the live docs. `pnpm build` therefore runs with + `--max-old-space-size=8192`. The bundle also has a hard ceiling: with 17 archived versions webpack + fails with `RangeError: Invalid string length`, so only final releases are archived and a + release's candidate snapshots are deleted once it ships (see `content/docs-archive/README.md`). + ## Available Scripts -| Command | Description | -| -------------------------- | --------------------------------------------- | -| `pnpm dev` | Start the dev server on port 3333 | -| `pnpm build` | Production build | -| `pnpm start` | Start the production server on port 3333 | -| `pnpm lint` | Run ESLint (zero warnings allowed) | -| `pnpm lint:prettier` | Check formatting with Prettier | -| `pnpm prettier` | Format the codebase with Prettier | -| `pnpm typecheck` | Generate MDX types and run `tsc --noEmit` | -| `pnpm test` | Run the Vitest suite | -| `pnpm test:watch` | Run Vitest in watch mode | -| `pnpm analyze` | Build and analyze the production bundle size | -| `pnpm optimize:images` | Optimize images in `public/` | -| `pnpm web-bot-auth:keygen` | Generate an Ed25519 Web Bot Auth private JWK | -| `pnpm translate` | Generate translations from the English source | +| Command | Description | +| --------------------------- | ------------------------------------------------------------ | +| `pnpm dev` | Start the dev server on port 3333 | +| `pnpm build` | Production build | +| `pnpm start` | Start the production server on port 3333 | +| `pnpm lint` | Run ESLint (zero warnings allowed) | +| `pnpm lint:prettier` | Check formatting with Prettier | +| `pnpm prettier` | Format the codebase with Prettier | +| `pnpm typecheck` | Generate MDX types and run `tsc --noEmit` | +| `pnpm test` | Run the Vitest suite | +| `pnpm test:watch` | Run Vitest in watch mode | +| `pnpm check:config-version` | Fail if any `librechat.yaml` snippet has a stale `version:` | +| `pnpm sync:config-version` | Rewrite stale `librechat.yaml` `version:` snippets to latest | +| `pnpm analyze` | Build and analyze the production bundle size | +| `pnpm optimize:images` | Optimize images in `public/` | +| `pnpm web-bot-auth:keygen` | Generate an Ed25519 Web Bot Auth private JWK | +| `pnpm translate` | Generate translations from the English source | +| `pnpm docs:archive` | Snapshot the English docs into `content/docs-archive` | + +The config version is sourced from the newest `content/changelog/config_v*.mdx` entry, whose +`version:` frontmatter must match its filename. Never hand-edit the `version:` line in a docs +snippet — run `pnpm sync:config-version`, which updates every `librechat.yaml` code fence under +`content/docs` (all locales included) and leaves unrelated YAML alone. ## Contributing diff --git a/app/[lang]/docs/[[...slug]]/page.ts b/app/[lang]/docs/[[...slug]]/page.ts index af251be6d..d434b3498 100644 --- a/app/[lang]/docs/[[...slug]]/page.ts +++ b/app/[lang]/docs/[[...slug]]/page.ts @@ -1,17 +1,33 @@ -import { generateDocsMetadata, generateLocalizedDocsParams, renderDocsPage } from '@/lib/docs-page' +import { + generateArchivedDocsMetadata, + generateArchivedDocsParams, + generateDocsMetadata, + generateLocalizedDocsParams, + renderArchivedDocsPage, + renderDocsPage, +} from '@/lib/docs-page' +import { isArchivedVersion } from '@/lib/docs-archive' interface PageProps { params: Promise<{ lang: string; slug?: string[] }> } export default async function Page({ params }: PageProps) { - return renderDocsPage(await params) + const { lang, slug } = await params + + if (isArchivedVersion(lang)) return renderArchivedDocsPage({ version: lang, slug }) + + return renderDocsPage({ lang, slug }) } export function generateStaticParams() { - return generateLocalizedDocsParams() + return [...generateLocalizedDocsParams(), ...generateArchivedDocsParams()] } export async function generateMetadata({ params }: PageProps) { - return generateDocsMetadata(await params) + const { lang, slug } = await params + + if (isArchivedVersion(lang)) return generateArchivedDocsMetadata({ version: lang, slug }) + + return generateDocsMetadata({ lang, slug }) } diff --git a/app/[lang]/docs/layout.ts b/app/[lang]/docs/layout.ts index 2dc6eaa0e..6ae6f29ef 100644 --- a/app/[lang]/docs/layout.ts +++ b/app/[lang]/docs/layout.ts @@ -1,6 +1,13 @@ +import { isArchivedVersion } from '@/lib/docs-archive' import { renderDocsLayout } from '@/lib/docs-layout' +import { i18n } from '@/lib/i18n' import type { ReactNode } from 'react' +/** + * The `[lang]` segment carries either a locale (`/es/docs/...`) or an archived + * docs version (`/v0.7.x/docs/...`) — Next.js can't give `/docs/[[...slug]]` a + * dynamic sibling segment, so the version shares the locale slot. + */ export default async function Layout({ params, children, @@ -9,5 +16,10 @@ export default async function Layout({ children: ReactNode }) { const { lang } = await params + + if (isArchivedVersion(lang)) { + return renderDocsLayout({ lang: i18n.defaultLanguage, children, archivedVersion: lang }) + } + return renderDocsLayout({ lang, children }) } diff --git a/components/ArchivedVersionBanner.tsx b/components/ArchivedVersionBanner.tsx new file mode 100644 index 000000000..3ceae4d26 --- /dev/null +++ b/components/ArchivedVersionBanner.tsx @@ -0,0 +1,46 @@ +import { Callout } from 'fumadocs-ui/components/callout' + +/** + * Shown at the top of every archived docs page. `currentHref` is the same slug + * on the live docs when it still exists, otherwise the live docs root. The + * newest release is still what most deployments run, so it is introduced as + * the latest release rather than as frozen, unmaintained docs. + */ +export function ArchivedVersionBanner({ + version, + currentHref, + latestRelease = false, +}: { + version: string + currentHref: string + latestRelease?: boolean +}) { + if (latestRelease) { + return ( + +

+ You are reading the documentation for {version}, the latest release. The + main docs follow the development branch and can describe features that are not released + yet.{' '} + + Go to the latest docs + + . +

+
+ ) + } + + return ( + +

+ You are reading the frozen documentation for {version}, which is no longer + maintained.{' '} + + Go to the latest docs + + . +

+
+ ) +} diff --git a/components/VersionSwitcher.tsx b/components/VersionSwitcher.tsx index e32f8a389..7febb066a 100644 --- a/components/VersionSwitcher.tsx +++ b/components/VersionSwitcher.tsx @@ -9,26 +9,28 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { versions } from '@/lib/versions' +import type { DocsVersionOption } from '@/lib/versions' import { i18n } from '@/lib/i18n' import { getUI } from '@/lib/ui-i18n' import { cn } from '@/lib/utils' /** - * Docs version switcher shown at the top of the sidebar. Reads the registry in - * lib/versions.ts; the dropdown grows as archived versions are added. + * Docs version switcher shown at the top of the sidebar. The options are built + * on the server from the archived versions present in content/docs-archive + * (see lib/docs-archive.ts), so the dropdown grows by adding content. * - * The active version is derived from the current pathname (the registry entry - * whose base URL is the longest matching prefix) so the trigger and checkmark - * stay correct while reading archived docs. Links keep the active locale so - * localized readers aren't bounced back to the default-language docs. + * The active version is derived from the current pathname (the option whose + * base URL is the longest matching prefix) so the trigger and checkmark stay + * correct while reading archived docs. Links for the live docs keep the active + * locale so localized readers aren't bounced back to the default-language + * docs; archived snapshots are English-only and keep their plain URL. */ -export function VersionSwitcher() { +export function VersionSwitcher({ options }: { options: DocsVersionOption[] }) { const pathname = usePathname() ?? '/' // hideLocale: 'default-locale' keeps the default language at /docs with no // prefix; other locales live under //docs. Pull the locale off the - // path so we can rebuild it onto every version link. + // path so we can rebuild it onto the live docs link. const [firstSegment] = pathname.split('/').filter(Boolean) const locale = firstSegment && firstSegment !== i18n.defaultLanguage && i18n.languages.includes(firstSegment) @@ -37,20 +39,33 @@ export function VersionSwitcher() { const localePrefix = locale ? `/${locale}` : '' const t = getUI(locale ?? i18n.defaultLanguage).version - // Strip the locale prefix to compare against the registry's canonical URLs. + // Strip the locale prefix to compare against the options' canonical URLs. const canonicalPath = locale ? pathname.slice(localePrefix.length) || '/' : pathname - // Active version = the entry whose base URL is the longest prefix of the path, - // falling back to the registry's latest flag (e.g. on /docs itself). + // Active version = the option whose base URL is the longest prefix of the + // path, falling back to the live docs (e.g. on /docs itself). const active = - [...versions] + [...options] .sort((a, b) => b.url.length - a.url.length) - .find((v) => canonicalPath === v.url || canonicalPath.startsWith(`${v.url}/`)) ?? - versions.find((v) => v.current) ?? - versions[0] + .find( + (option) => canonicalPath === option.url || canonicalPath.startsWith(`${option.url}/`), + ) ?? + options.find((option) => option.current) ?? + options[0] if (!active) return null + // Nothing to switch to until a version is archived: show the version as a + // label instead of a dropdown that cannot go anywhere. + if (options.length < 2) { + return ( +
+ {t.label} + {active.label} +
+ ) + } + return ( - {versions.map((version) => ( - + {options.map((option) => ( + - {version.label} + {option.label}