diff --git a/docs/chronicle.yaml b/docs/chronicle.yaml index 305a6594..61ca1385 100644 --- a/docs/chronicle.yaml +++ b/docs/chronicle.yaml @@ -2,6 +2,12 @@ site: title: Chronicle description: Config-driven documentation framework +url: https://chronicle.raystack.org + +logo: + light: /logo.svg + dark: /logo-dark.svg + content: - dir: docs label: Docs @@ -18,6 +24,30 @@ search: enabled: true placeholder: Search docs... +# The docs were reorganised into groups. These keep every old link working. +redirects: + - from: /docs/features + to: /docs + permanent: true + - from: /docs/cli + to: /docs/reference/cli + permanent: true + - from: /docs/configuration + to: /docs/reference/config + permanent: true + - from: /docs/frontmatter + to: /docs/reference/frontmatter + permanent: true + - from: /docs/components + to: /docs/writing/components + permanent: true + - from: /docs/image-optimization + to: /docs/writing/images + permanent: true + - from: /docs/docker + to: /docs/deploy/docker + permanent: true + telemetry: enabled: true diff --git a/docs/content/docs/deploy/build.mdx b/docs/content/docs/deploy/build.mdx new file mode 100644 index 00000000..55a9151a --- /dev/null +++ b/docs/content/docs/deploy/build.mdx @@ -0,0 +1,107 @@ +--- +title: Build and serve +description: What a production build produces, and how presets change it. +order: 1 +--- + +```bash +chronicle build +chronicle start +``` + +`build` writes the production site. `start` serves what `build` produced — it +does not build for you, so the order matters. `chronicle serve` runs both, which +is handy locally and wrong in a deployment, where you want the build to happen +once and the server to start many times. + +## Two kinds of output + +The `preset` decides which you get, and it is the only decision that really +matters here. + +**A server build** produces a small server. Pages are rendered per request, +which is what makes search, image resizing and the API request tester work. +This is the default. + +**A static build** produces a single-page app: one `index.html`, a JavaScript +bundle, and a small JSON file per page that the app fetches as the reader +navigates. There is no process to run and nothing to keep alive. + +```yaml +preset: static +``` + +Or per build: + +```bash +chronicle build --preset static +``` + +## What you give up going static + +There is no server, so the parts that needed one change: + +| | Server build | Static build | +|---|---|---| +| Page HTML | Rendered per request, works with JavaScript off | One shell; pages filled in by JavaScript | +| Search | Queried per request | Whole index downloaded, searched in the browser | +| Images | Resized on demand, then cached | Resized once, during the build | +| API request tester | Proxies through your server | Not available | +| Health and readiness | `/api/health`, `/api/ready` | Not available | +| Redirects | Served by Chronicle | Your host has to do them | + +Everything else is generated either way: navigation, versions, the API +reference, markdown URLs, `llms.txt`, the sitemap and social cards. + +The first row matters most. A server build sends finished HTML, so a crawler or +a reader with JavaScript off gets the page. A static build sends a shell, so +they get very little. If search ranking matters to you, that is the argument for +a server build. + +Pick static for a small or medium site you want on a CDN with nothing to +operate. Pick a server build for a large site, when search needs to stay fast, +or when the request tester matters. + +## Where the output goes + +| Preset | Output directory | +|---|---| +| unset, `node-server`, `cloudflare` | `.output/` | +| `static`, `cloudflare-pages`, `github-pages` | `.output/public/` | +| `vercel`, `vercel-static` | `.vercel/output/` | + +A static host has to send unknown paths to `index.html`, because every page +shares that one file. Getting this wrong is the usual reason a deep link 404s +while the home page works — see [Deploy](/docs/deploy/hosting). + +Gitignore whichever applies. `chronicle init` adds `.output` for you. + +## Build failures + +The build stops on a page it cannot parse, and names the file and the line. This +is deliberate — a docs site that silently drops a broken page is worse than one +that refuses to build. + +The usual cause is MDX being stricter than markdown about `<` and `{`, which it +reads as the start of a component or an expression. Wrap the character in +backticks. + +## In CI + +```bash +bun install +bun run chronicle build --preset static +``` + +Then publish the output directory. The build needs no network access beyond +installing packages, and no services. + +If your build machine is not the machine that serves the site, remember `start` +needs the `.output/` directory that `build` wrote — copy it, or build on the +host. + +## Next + +- [Deploy](/docs/deploy/hosting) — putting the output on a host +- [Docker](/docs/deploy/docker) — running from the container image +- [Monitoring](/docs/deploy/monitoring) — health checks and metrics diff --git a/docs/content/docs/docker.mdx b/docs/content/docs/deploy/docker.mdx similarity index 94% rename from docs/content/docs/docker.mdx rename to docs/content/docs/deploy/docker.mdx index 75eaf10a..edb3cdec 100644 --- a/docs/content/docs/docker.mdx +++ b/docs/content/docs/deploy/docker.mdx @@ -1,11 +1,9 @@ --- title: Docker -description: Run Chronicle with Docker. -order: 8 +description: Run Chronicle from the official container image. +order: 3 --- -# Docker - Chronicle is available as a Docker image on [Docker Hub](https://hub.docker.com/r/raystack/chronicle). ## Pull the Image diff --git a/docs/content/docs/deploy/hosting.mdx b/docs/content/docs/deploy/hosting.mdx new file mode 100644 index 00000000..a35cdde7 --- /dev/null +++ b/docs/content/docs/deploy/hosting.mdx @@ -0,0 +1,186 @@ +--- +title: Deploy +description: Put a built site on Vercel, Cloudflare, a Node host, or any static host. +order: 2 +--- + +Pick the preset that matches your host, build, and publish the output. The +preset is the only Chronicle-specific part — everything after it is your host's +normal workflow. + +Set it in the config so every build agrees: + +```yaml +preset: vercel +``` + +Or pass it per build, which is what you want if the same repository deploys to +more than one place: + +```bash +chronicle build --preset static +``` + +## Vercel + +```yaml +preset: vercel +``` + +```bash +chronicle build +``` + +The build writes `.vercel/output`, which is the directory Vercel deploys +directly. Set the build command to `chronicle build` and leave the output +directory at its default. + +For a site that does not need per-request search or the API request tester, use +`vercel-static` instead. Same output location, built as a single-page app. + +## Cloudflare + +For Workers: + +```yaml +preset: cloudflare +``` + +The build writes `.output/`. Deploy it with Wrangler. + +For Pages, use the static preset built for it: + +```yaml +preset: cloudflare-pages +``` + +That writes `.output/public/`. Point your Pages project at that directory. + +## A Node host + +Any host that runs a Node process — a VM, a container platform, a PaaS. + +```yaml +preset: node-server +``` + +```bash +chronicle build +chronicle start --port 3000 --host 0.0.0.0 +``` + +`--host 0.0.0.0` matters. The default binds to localhost, which works on your +machine and refuses connections from outside a container. + +Point the platform's health check at `/api/health`, and its readiness check at +`/api/ready` if it has a separate one. See +[Monitoring](/docs/deploy/monitoring). + +There is a container image if you would rather not build your own — see +[Docker](/docs/deploy/docker). + +## GitHub Pages + +```yaml +preset: github-pages +``` + +The build writes `.output/public/`. A workflow that builds and publishes it: + +```yaml +name: docs +on: + push: + branches: [main] + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run chronicle build --preset github-pages + # GitHub Pages cannot rewrite unknown paths, but it does serve 404.html + # for them — so a copy of the app shell makes deep links work. + - run: cp .output/public/index.html .output/public/404.html + - uses: actions/upload-pages-artifact@v3 + with: + path: .output/public + deploy: + needs: build + runs-on: ubuntu-latest + environment: github-pages + steps: + - uses: actions/deploy-pages@v4 +``` + +## Any other static host + +```yaml +preset: static +``` + +`chronicle build` writes `.output/public/`. Upload it to S3, Netlify, a CDN, or +an nginx document root. There is nothing to run. + +### Send unknown paths to index.html + +This one step catches most people. A static build is a single-page app, so every +page is served by the same `index.html`. Without a rewrite rule the home page +works and `/docs/quick-start` returns a 404. + +On nginx: + +```nginx +location / { + try_files $uri $uri/ /index.html; +} +``` + +On Netlify, a `_redirects` file in the published directory: + +``` +/* /index.html 200 +``` + +Most CDNs call this a "SPA fallback" or "rewrite to index". Whatever the name, +it is the same rule. + +## Set `url` before you ship + +```yaml +url: https://docs.example.com +``` + +Without it there are no absolute URLs for the sitemap, no canonical link tags, +and no social cards — a social network needs an absolute address to fetch a card +image from. It is the single easiest thing to forget and the one most visible +once the site is public. + +## Redirects survive the move + +If your docs used to live somewhere else, or you moved pages during the +migration, `redirects` in the config are served by Chronicle itself. They work +the same on every server preset. + +Static presets have no server to run them, so on a static host use the host's +own redirect mechanism — the same place you configured the rewrite above. See +[Links and redirects](/docs/writing/links). + +## A checklist + +Before you call it done: + +- `url` is set +- `/sitemap.xml` and `/robots.txt` return something sensible +- Search finds a page you know exists +- A shared link shows a social card +- Old URLs still resolve, if any moved +- Health check points at `/api/health`, on a server build +- A deep link like `/docs/quick-start` loads directly, on a static build diff --git a/docs/content/docs/deploy/meta.json b/docs/content/docs/deploy/meta.json new file mode 100644 index 00000000..d1fda0e2 --- /dev/null +++ b/docs/content/docs/deploy/meta.json @@ -0,0 +1 @@ +{ "title": "Deploy and operate", "order": 8 } diff --git a/docs/content/docs/deploy/monitoring.mdx b/docs/content/docs/deploy/monitoring.mdx new file mode 100644 index 00000000..e339b40f --- /dev/null +++ b/docs/content/docs/deploy/monitoring.mdx @@ -0,0 +1,120 @@ +--- +title: Monitoring +description: Health checks, Prometheus metrics, and page analytics for a running site. +order: 4 +--- + +Everything here applies to a server build. A static site has no server, so it has +nothing to report — see [Build and serve](/docs/deploy/build). + +## Health and readiness + +Two endpoints, and they answer different questions. Most platforms want both. + +### /api/health + +Is the process alive? Always returns `200` while the server is running. Wire +your platform's liveness check here — if this stops answering, restart the +process. + +```bash +curl -i https://docs.example.com/api/health +``` + +### /api/ready + +Is it ready to serve properly? Returns `200` once the search index has finished +building, and `503` before that. + +```json +{ "status": "ready", "search": true } +``` + +```json +{ "status": "not_ready", "search": false } +``` + +The first request to this endpoint is what starts the index building, in the +background. A large site is briefly up but not ready. Point your readiness or +startup check here and a rolling deploy will not send traffic to an instance +whose search is still empty — and will get the indexing under way. + +Using `/api/health` for both is the common mistake. It reports success +immediately, so traffic arrives before search works. + +## Prometheus metrics + +Off by default. Turn it on and metrics are exported on a separate port: + +```yaml +telemetry: + enabled: true + serviceName: my-docs + port: 9090 +``` + +```bash +curl http://localhost:9090/metrics +``` + +The output is standard Prometheus exposition format, carrying request counts, +status codes and durations by route. + +The separate port is deliberate — it lets you scrape metrics from inside your +network without exposing them on the public site. Do not publish port 9090. + +A scrape config: + +```yaml +scrape_configs: + - job_name: docs + static_configs: + - targets: ['docs-internal:9090'] +``` + +`serviceName` is the name your traces and metrics are tagged with. Set it if you +run more than one docs site, or every one of them reports as `chronicle`. + +## Page analytics + +Separate from the above, and about readers rather than the server: + +```yaml +analytics: + enabled: true + googleAnalytics: + measurementId: G-XXXXXXXXXX +``` + +Both fields are needed — `enabled: true` on its own does nothing without a +measurement ID. + +Page views are tracked as readers navigate, including navigation within the site +that never reloads the page. + +### Outbound link tracking + +Links in the sidebar footer, set through `links`, are tagged with UTM parameters +so the destination can see where the visit came from: + +| Parameter | Value | +|---|---| +| `utm_source` | The docs site's hostname | +| `utm_medium` | Your site title, slugified | +| `utm_content` | The path the reader clicked from | + +This needs nothing turned on and works whether or not analytics is enabled. It +is the destination's analytics that benefits. See +[`links`](/docs/reference/config). + +## What to watch + +For a docs site, the numbers worth an alert are few: + +- `/api/health` failing — the process is gone +- `/api/ready` stuck at `503` well past startup — the index is failing to build +- A rise in 404s — usually a moved page with no redirect. See + [Links and redirects](/docs/writing/links) + +Response times rarely need watching. Pages are rendered from files already in +memory, and there is no database behind them. diff --git a/docs/content/docs/features.mdx b/docs/content/docs/features.mdx deleted file mode 100644 index 21551220..00000000 --- a/docs/content/docs/features.mdx +++ /dev/null @@ -1,146 +0,0 @@ ---- -title: Features -description: Overview of Chronicle features -order: 2 ---- - -# Features - -Chronicle is a self-hosted documentation platform built with Vite + Nitro. - -## Content - -- **MDX support** — write documentation in MDX with React component embedding -- **Frontmatter** — `title`, `short`, `description`, `identifiers`, `order`, `icon`, `lastModified`, `authors`, `draft` -- **Directory metadata** — `meta.json` for folder titles, ordering, and sidebar config -- **Remark plugins** — directives, admonitions, image resolution, link resolution, mermaid, reading time -- **Syntax highlighting** — powered by Shiki via Apsara CodeBlock -- **Versioning** — multiple documentation versions with URL-based routing - -## API Reference - -- **OpenAPI / Swagger support** — auto-generates API reference pages from specs (OpenAPI 3.x and Swagger 2.0) -- **Read-only overview** — field names, types, required badges, examples, response schemas -- **Playground dialog** — test requests with editable fields, JSON body editor, auth switching -- **Auth types** — API Key, Bearer Token, Basic Auth (auto-detected from spec `securitySchemes`) -- **Code snippets** — cURL, Python, Go, TypeScript with language switcher -- **Response panel** — status code tabs with JSON syntax highlighting -- **`.md` export** — every API endpoint has a `.md` URL with full documentation - -## Navigation - -- **Sidebar** — auto-generated from file structure, configurable via `meta.json` -- **Breadcrumbs** — shows path hierarchy for docs and API pages -- **Prev/Next** — arrow navigation between pages and API endpoints -- **Search** — full-text search with SQLite FTS5 across titles, headings, and body content -- **Folder sorting** — via `order` in `meta.json` -- **Page sorting** — via `order` in frontmatter or `pages` array in `meta.json` - -## Themes - -- **Default theme** — sidebar + content layout with sub-navigation bar -- **Paper theme** — book-style single-column with reading progress -- **Fanfold theme** — continuous-form line printer paper, monospace throughout -- **Dark/light mode** — system preference or manual toggle - -## SEO & AI - -- **Meta tags** — auto-generated title, description, Open Graph, Twitter Card -- **Sitemap** — auto-generated `sitemap.xml` -- **robots.txt** — auto-generated -- **llms.txt** — AI-discoverable documentation index -- **`.md` URLs** — every page (docs and API) has a markdown URL for AI tools -- **Open in AI** — copy as markdown, open in ChatGPT or Claude - -## Developer Experience - -- **CLI** — `chronicle dev`, `chronicle build`, `chronicle start` -- **Hot reload** — instant updates during development -- **Monorepo support** — works as a package in monorepos -- **Docker support** — containerized deployment -- **Zod-validated config** — `chronicle.yaml` with schema validation - -## Redirects - -Configure URL redirects in `chronicle.yaml` for migrating old routes. - -```yaml -redirects: - - from: /old-page - to: /docs/getting-started - - from: /legacy/api - to: /apis - permanent: true -``` - -- `permanent: false` (default) — 307 temporary redirect -- `permanent: true` — 308 permanent redirect - -See [Configuration](/docs/configuration#redirects) for full reference. - -## Sorting - -### Pages - -Add `order` to frontmatter. Lower numbers appear first. - -```mdx ---- -title: Introduction -order: 1 ---- -``` - -Or use `pages` array in `meta.json`: - -```json -{ - "pages": ["introduction", "installation", "configuration"] -} -``` - -### Folders - -Add `order` to the folder's `meta.json`: - -```json -{ - "title": "Getting Started", - "order": 1 -} -``` - -Folder sorting is controlled only by `meta.json` `order`. The index page frontmatter `order` does not affect folder position. - -## Markdown URLs - -Every page has a `.md` URL that returns raw markdown: - -- **Docs pages** — `/{slug}.md` returns the raw MDX content -- **API endpoints** — `/apis/{spec}/{operationId}.md` generates markdown with parameters, examples, responses, and cURL - -```bash -curl https://docs.example.com/docs/getting-started.md -curl https://docs.example.com/apis/petstore/findPetsByStatus.md -``` - -The "Open in AI" dropdown uses these URLs to copy markdown, open in ChatGPT, or open in Claude. - -## API Reference Page - -### Overview - -The read-only overview shows endpoint title, method badge, path, authorisation fields, query/path parameters, request body, and response schemas. The right column has code snippets and response JSON. - -### Playground - -Click **Test request** in the navbar to open the playground dialog with editable fields, JSON body editor, auth type switching, and live response with status and timing. - -### View Documentation - -If the OpenAPI spec has `externalDocs`, a **View documentation** button appears in the navbar. - -## Health & Readiness - -- `GET /api/health` — liveness probe, always returns `200` -- `GET /api/ready` — readiness probe, returns `200` when search index is built, `503` otherwise diff --git a/docs/content/docs/guides/api-reference.mdx b/docs/content/docs/guides/api-reference.mdx new file mode 100644 index 00000000..cf18793a --- /dev/null +++ b/docs/content/docs/guides/api-reference.mdx @@ -0,0 +1,145 @@ +--- +title: API reference +description: Turn an OpenAPI spec into browsable endpoint pages with a request tester. +order: 2 +--- + +Point Chronicle at an OpenAPI file and it renders a reference: one page per +endpoint, with parameters, request and response schemas, code samples in four +languages, and a panel for sending real requests. + +You write no pages for this. The spec is the source. + +## Set it up + +Put the spec next to `chronicle.yaml` and add an `api` entry: + +```yaml +api: + - name: Petstore API + spec: ./petstore.yaml + basePath: /apis + server: + url: https://petstore.swagger.io/v2 + description: Production +``` + +Restart and the reference is at `/apis`. Each endpoint gets its own URL under +that, and they all appear in the sidebar grouped by tag, with a coloured badge +for the method. + +`spec` accepts JSON or YAML, and both OpenAPI 3.x and Swagger 2.0. A Swagger 2.0 +file is converted as it loads, so you do not have to migrate it first. + +Several APIs at once is just several entries, each with its own `basePath`: + +```yaml +api: + - name: Public API + spec: ./public.yaml + basePath: /apis + server: { url: https://api.example.com } + - name: Admin API + spec: ./admin.yaml + basePath: /admin-apis + server: { url: https://admin.example.com } +``` + +## What a reader gets + +**The endpoint page.** Method and path at the top, then the parameters — path, +query and header — with types, required markers, descriptions and examples. Then +the request body schema, then a response schema per status code. Nested objects +expand in place. + +**Code samples.** cURL, Python, Go and TypeScript, generated from the endpoint +and switchable from a tab strip. They include the real server URL and the auth +header, so they are ready to paste. + +**A request tester.** The **Test request** button opens a panel with the +parameters as editable fields and a JSON editor for the body. Sending shows the +status, the timing, and the response body with syntax highlighting. + +**A markdown version.** Every endpoint answers at a `.md` URL with the whole +thing — parameters, examples, responses and a cURL command — as plain text, for +handing to an AI tool. + +## Authentication + +Declare how the API authenticates and the reference picks it up: + +```yaml +api: + - name: Petstore API + spec: ./petstore.yaml + basePath: /apis + server: + url: https://petstore.swagger.io/v2 + auth: + type: apiKey + header: Authorization + placeholder: "Bearer your-token" +``` + +The auth field then appears on every endpoint page and in the request tester, +and the value a reader types is written into the code samples. + +If the spec declares `securitySchemes`, Chronicle reads those instead and offers +the schemes it finds — API key, bearer token or basic auth. The `auth` config +above is for a spec that does not say. + +### The request tester and CORS + +Requests go through a proxy on your docs server rather than straight from the +browser. That is deliberate: most APIs do not allow cross-origin requests from a +docs site, so a direct call would fail before it left the page. + +It also means the token a reader types reaches your docs server on its way to +the API. It is not stored or logged, but the server does handle it — so tell +readers to use a test credential on a docs site you do not control, and treat +your own docs host as something that needs the same trust as the API behind it. + +This means the API has to be reachable from wherever your docs are hosted. An +API on a private network works when you run `chronicle dev` on the same network +and does not work from a public host. + +## Linking to your own guides + +If the spec has an `externalDocs` entry, a **View documentation** button appears +on the endpoint page. Use it to send someone from a bare endpoint to the guide +that explains why they would call it. + +## One spec per version + +A versioned site can give each version its own spec, pointed at that version's +server: + +```yaml +versions: + - dir: v1 + label: "1.0" + content: + - dir: docs + label: Docs + api: + - name: REST API (v1) + spec: ./v1-openapi.yaml + basePath: /apis + server: + url: https://api.example.com/v1 +``` + +That serves at `/v1/apis/...`. See +[Versioned documentation](/docs/guides/versioning). + +## Things to know + +**The spec is read at startup.** Change the file and restart the dev server. + +**Endpoints are indexed for search** alongside your written pages, by method, +path and summary, so `POST /pets` finds the endpoint. + +**A spec that fails to parse fails the build** rather than rendering an empty +reference. The error names the file. + +See [`api`](/docs/reference/config) for every field. diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json new file mode 100644 index 00000000..c9867da9 --- /dev/null +++ b/docs/content/docs/guides/meta.json @@ -0,0 +1 @@ +{ "title": "Guides", "order": 5 } diff --git a/docs/content/docs/guides/migrate.mdx b/docs/content/docs/guides/migrate.mdx new file mode 100644 index 00000000..1a3fa742 --- /dev/null +++ b/docs/content/docs/guides/migrate.mdx @@ -0,0 +1,134 @@ +--- +title: Move an existing docs site +description: Point Chronicle at markdown you already have. +order: 5 +--- + +If you already have a folder of markdown, you do not need to move or rename any +of it. Chronicle can read the folder where it sits. + +## Point init at your folder + +```bash +chronicle init -c docs +``` + +This writes a `chronicle.yaml` that treats your existing `docs/` as the content +directory. Nothing is moved, and the sample page is skipped because the folder +already has files. + +Then: + +```bash +chronicle dev +``` + +Most of a markdown site renders on the first try. What follows is the short list +of things that usually need a look. + +## Add a title to every page + +Give every page a `title`. Nothing fails without one — the page renders as +`Untitled` in the sidebar and the browser tab, which is easy to miss until a +reader finds it. + +If your pages open with an `# H1` and no frontmatter, that heading is the title — +move it up: + +```mdx +# Installing the CLI → --- + title: Installing the CLI +The CLI ships as a binary. --- + + The CLI ships as a binary. +``` + +Delete the `#` when you do. Every theme prints `title` above the article, so +leaving both shows the same words twice. See +[Pages and frontmatter](/docs/writing/pages). + +A quick way to find the pages that still need it: + +```bash +grep -rL "^title:" docs --include="*.md" --include="*.mdx" +``` + +## Rename .md to .mdx + +Both extensions are read, so this is optional. Renaming buys you components — +callouts, tabs, diagrams — in pages that want them, and costs nothing in pages +that do not. + +MDX is stricter than markdown in one way worth knowing: a bare `<` or `{` is +read as the start of a component or an expression. If a page fails to parse +after renaming, that is usually why. Wrap the character in backticks or escape +it. + +## Order the pages + +Without `order`, pages sort after every page that has one. Add it to the pages +whose position matters: + +```yaml +--- +title: Introduction +order: 1 +--- +``` + +Folders become sidebar groups named after the folder. Give a folder a better +name with a `meta.json`: + +```json +{ "title": "Getting started", "order": 2 } +``` + +See [Navigation](/docs/writing/navigation). + +## Keep your old URLs working + +This is the step people skip and regret. If your paths change — a different +prefix, a renamed folder, a page split in two — add redirects so existing links +survive: + +```yaml +redirects: + - from: /guide/install + to: /docs/getting-started/install + permanent: true +``` + +Check your analytics for the twenty most-visited pages and make sure each one +still resolves. See [Links and redirects](/docs/writing/links). + +## Move your images + +Images referenced relatively keep working — put the file next to the page. Ones +you served from a static folder belong in `public/`, which is served as-is. + +Content images get resized and re-encoded for free once they are next to a page, +which files in `public/` do not. See [Images](/docs/writing/images). + +## Coming from a docs site with its own conventions + +Chronicle has no plugin system, so anything your previous tool did through +plugins needs another home: + +| You had | In Chronicle | +|---|---| +| A sidebar or nav config file | Folders plus `order`. See [Navigation](/docs/writing/navigation) | +| Custom admonition syntax | The `:::note` directives Chronicle ships. See [Components](/docs/writing/components) | +| A theme you had customised | One of three themes, plus colour tokens. See [Themes](/docs/themes) | +| Versioned docs in a plugin | The `versions` key. See [Versioned documentation](/docs/guides/versioning) | +| A search integration | Built in. See [Search](/docs/guides/search) | + +## Check before you ship + +```bash +chronicle build +``` + +The build fails on a page that cannot be parsed and names the file, so this is +the fastest way to find the last few problems. Then look at the sidebar order, +click through the twenty pages that matter most, and check that search finds +them. diff --git a/docs/content/docs/guides/search.mdx b/docs/content/docs/guides/search.mdx new file mode 100644 index 00000000..2b4bdc1c --- /dev/null +++ b/docs/content/docs/guides/search.mdx @@ -0,0 +1,76 @@ +--- +title: Search +description: What search indexes, how readers reach it, and how it behaves in a static build. +order: 3 +--- + +Search is on by default. There is no service to sign up for and no API key — the +index is built from your pages when the server starts. + +```yaml +search: + enabled: true + placeholder: Search docs... +``` + +Turn it off with `enabled: false` if your site is small enough that the sidebar +is faster than typing. + +## Reaching it + +A reader can click the search button in the sidebar or press Cmd + +KCtrl + K on Windows and Linux. Results +appear as they type. + +## What gets indexed + +Three fields per page, weighted so the best match wins: + +| Field | Comes from | +|---|---| +| Title | The page's `title` | +| Headings | Every `##` and below | +| Body | The page text | + +A result tells the reader which of the three matched. A heading match links +straight to that heading rather than the top of the page, so a hit deep in a +long page lands where the words actually are. + +API endpoints are indexed too, by method, path and summary — so `POST /pets` +finds the endpoint alongside the guides that mention it. + +Pages with `draft: true` are left out, because they are removed from the +navigation tree before the index is built. + +## Scoping to a version and section + +On a versioned site the index is per version. A reader on `/v1/docs` searches +1.0 and does not get 3.0 results. Switching versions switches the index. + +Results also carry the content section they came from, so a hit in `Dev Docs` is +labelled as such rather than looking like a page from the main docs. + +## Two engines, same behaviour + +Which engine runs depends on how you built the site, and readers should not be +able to tell. + +**Server builds** use SQLite full-text search. The index is built in the +background when the server starts, which is why `/api/ready` reports not-ready +until it finishes — see [Monitoring](/docs/deploy/monitoring). + +**Static builds** have no server to query, so the index is written to a JSON file +at build time and searched in the browser. This makes the first search on a +static site download the index. It is fine for a few hundred pages and gets +heavy well beyond that, which is a reason to prefer a server build for a large +site. See [Deploy](/docs/deploy/hosting). + +## Things to know + +**The index is built once per run.** In development, adding a page updates the +page tree immediately but the search index is built at startup — restart if a +new page is not being found. + +**There is no ranking you can tune.** No per-page boost, no synonyms, no +stop-word list. If a page is hard to find, the fix is a clearer `title` and +better headings, which helps readers who never search anyway. diff --git a/docs/content/docs/guides/sections.mdx b/docs/content/docs/guides/sections.mdx new file mode 100644 index 00000000..35357829 --- /dev/null +++ b/docs/content/docs/guides/sections.mdx @@ -0,0 +1,125 @@ +--- +title: Multiple content sections +description: Split a site into separate sections with their own navigation. +order: 4 +--- + +Some docs serve two audiences that share nothing. A product guide for people +using the thing, and a developer guide for people building against it. Putting +both in one sidebar makes each harder to read. + +A content directory is a section: its own folder, its own URL prefix, its own +navigation tree. + +## Declaring sections + +Each entry in `content` is a section: + +```yaml +content: + - dir: docs + label: Docs + - dir: dev + label: Dev Docs +``` + +``` +content/ +├── docs/ → /docs/... +└── dev/ → /dev/... +``` + +The directory name is the URL prefix. The label is what readers see. + +## What changes with two or more + +A switcher appears at the top of the sidebar. The tree below it shows only the +section you are in, so a reader in `Dev Docs` sees developer pages and nothing +else. Previous and next links stay inside the section too. + +Search still covers everything, but each result is labelled with the section it +came from — so a reader who searches across both can tell which is which. + +## Descriptions and icons + +On a landing page each section becomes a card, and these two fields are what +make the card worth reading: + +```yaml +content: + - dir: docs + label: Docs + description: Install it, configure it, and get your first site running. + icon: /icons/book.svg + - dir: dev + label: Dev Docs + description: Build against the API, extend the themes, run it in CI. + icon: /icons/code.svg +``` + +`icon` takes a path to a file in `public/`, or inline SVG markup. + +## The landing page + +With one section, `/` redirects straight into it — a landing page listing one +thing is a wasted click. + +With several, you probably want the choice. Turn it on: + +```yaml +latest: + label: "3.0" + landing: true +``` + +Now `/` lists the sections as cards. Leave it off and `/` redirects to the first +section in the list. + +The `fanfold` theme prints its own landing page here instead of the card grid — +see [Theme options](/docs/themes/options). + +## Where a section's root lands + +Visiting `/docs` normally serves `content/docs/index.mdx`. Set `index_page` to +send it to a different page instead: + +```yaml +content: + - dir: docs + label: Docs + index_page: overview +``` + +Now `/docs` resolves to `/docs/overview`. Useful when the page you want readers +to start on is not the one called `index`. + +## Sections and versions + +Each version declares its own sections, so they can change over releases: + +```yaml +content: + - dir: docs + label: Docs + - dir: dev + label: Dev Docs + +versions: + - dir: v1 + label: "1.0" + content: + - dir: docs + label: Docs # 1.0 had no developer guide +``` + +A version may rename a section, drop one, or add one the current version does +not have. See [Versioned documentation](/docs/guides/versioning). + +## Things to know + +**Section names must be unique**, and cannot collide with a version directory +name. The config check catches both. + +**Two sections is usually enough.** Every section you add is another thing a +reader has to choose between before they start reading. Folders inside one +section are cheaper — see [Navigation](/docs/writing/navigation). diff --git a/docs/content/docs/guides/versioning.mdx b/docs/content/docs/guides/versioning.mdx new file mode 100644 index 00000000..7dde9e7a --- /dev/null +++ b/docs/content/docs/guides/versioning.mdx @@ -0,0 +1,178 @@ +--- +title: Versioned documentation +description: Keep docs for old releases online while you write the next one. +order: 1 +--- + +When you ship a new major version, the people still on the old one need its docs. +Chronicle serves every version at once: the current one at the plain URLs, and +each older one behind its own prefix. + +## The layout + +The current version lives in `content/`, exactly as it does on an unversioned +site. Older versions live in `versions/`, one folder each: + +``` +my-docs/ +├── chronicle.yaml +├── content/ ← the current version +│ └── docs/ +│ └── index.mdx → /docs +└── versions/ + ├── v2/ + │ └── docs/ + │ └── index.mdx → /v2/docs + └── v1/ + └── docs/ + └── index.mdx → /v1/docs +``` + +The folder name under `versions/` becomes the URL prefix. Nothing else derives +from it, so `v1`, `2024-06` and `legacy` are all fine. + +## The config + +Two keys. `latest` describes the version in `content/`, and `versions` lists the +older ones: + +```yaml +content: + - dir: docs + label: Docs + +latest: + label: "3.0" + +versions: + - dir: v2 + label: "2.0" + content: + - dir: docs + label: Docs + + - dir: v1 + label: "1.0" + content: + - dir: docs + label: Docs +``` + +`latest` becomes required the moment you declare `versions` — without it the +switcher has no name for the current version. The config check will tell you so +at startup rather than letting the site render a blank entry. + +Each version declares its own `content`, because the sections of your docs +change over releases. A version can rename a section, drop one, or add one that +the current version does not have. + +## Cutting a new version + +The move is to copy the current docs sideways, then keep writing in `content/`: + +```bash +# freeze today's docs as 2.0 +cp -r content versions/v2 +``` + +Then add the entry to `versions`, and change `latest.label` to the new number: + +```yaml +latest: + label: "3.0" # was 2.0 + +versions: + - dir: v2 + label: "2.0" # the copy you just made + content: + - dir: docs + label: Docs +``` + +`content/` is now 3.0 and `/v2/docs` serves what you froze. No page needed +editing to make that happen. + +## What readers see + +A switcher appears in the sidebar, listing `latest` and every version. Picking +one keeps them on the equivalent page where possible. + +Everything else scopes to the version they are in. The sidebar shows that +version's pages, search only returns that version's results, and previous and +next links stay inside it. A reader on `/v1/docs` will not be dropped into 3.0 +material by accident. + +## Marking a version deprecated + +Add a badge and it shows next to the version's name in the switcher: + +```yaml +versions: + - dir: v1 + label: "1.0" + badge: + label: deprecated + variant: warning + content: + - dir: docs + label: Docs +``` + +`variant` takes `accent`, `warning`, `danger`, `success`, `neutral` or +`gradient`. `warning` for something you would rather people left, `danger` for +one that is no longer supported at all. + +## Version landing pages + +By default, hitting a version's root redirects to its first content directory — +`/v1` sends the reader to `/v1/docs`. + +Set `landing: true` to render a page listing that version's sections instead: + +```yaml +versions: + - dir: v1 + label: "1.0" + landing: true + content: [...] +``` + +This is worth it for a version with several sections and not worth it for one +with a single section, where the landing page is a list of one. + +`latest.landing` does the same for `/`. + +## Versioned API references + +A version can carry its own OpenAPI spec, served under its prefix: + +```yaml +versions: + - dir: v1 + label: "1.0" + content: + - dir: docs + label: Docs + api: + - name: REST API (v1) + spec: ./v1-openapi.yaml + basePath: /apis + server: + url: https://api.example.com/v1 +``` + +That renders at `/v1/apis/...`, pointing at the v1 server. See +[API reference](/docs/guides/api-reference). + +## Things to know + +**A version directory name cannot collide with a content directory name.** If +your content has `dir: docs`, no version may be called `docs` — the URLs would be +ambiguous. The config check catches this. + +**Old versions are frozen by hand, not by git.** Chronicle reads whatever is in +`versions/`. If you fix a typo in the current docs and want the fix in 2.0 as +well, you edit both. + +**Every version is built and served.** Ten versions of a large site means ten +sites' worth of pages in one build. Prune the versions nobody reads. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 4240051e..305360c2 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -1,95 +1,71 @@ --- -title: Getting Started -description: Install and set up Chronicle, a config-driven documentation framework. +title: Introduction +description: What Chronicle is, when to reach for it, and where to go next. order: 1 --- -# Getting Started +Chronicle turns a folder of MDX files into a documentation site. You write pages, +you describe the site in one YAML file, and that is the whole setup. There is no +JavaScript config, no plugin list, and no build script to maintain. -Chronicle is a config-driven documentation framework built on Vite, Nitro, and Apsara UI components. Write MDX content, configure with a single YAML file, and get a fully themed documentation site. +It is built on Vite and Nitro, and renders with Apsara components. Pages are +server-rendered, so they work with JavaScript turned off and load fast on a +slow connection. -## Installation +## When to reach for it -```bash -bun add @raystack/chronicle -``` - -## Quick Start - -### 1. Initialize a new project - -```bash -chronicle init -``` +Chronicle suits a project that needs more than a README and less than a custom +site. It is a good fit when you want: -This creates: -- `chronicle.yaml` — your site configuration -- `content/` — content directory with a sample `index.mdx` -- `.gitignore` — with `node_modules`, `dist`, `.output` entries +- **Docs in your repository.** Pages are files. They review like code. +- **More than one version live at once.** Old versions stay reachable at their + own URLs while you keep writing the new one. +- **An API reference from your OpenAPI spec.** Point at the spec file and you + get browsable endpoint pages with a request tester. +- **Something to hand to an AI tool.** Every page has a plain markdown URL, and + the site publishes an index at `llms.txt`. -To use an existing directory as content (e.g. `docs/`): +It is a poor fit if you need a full marketing site, a CMS with a web editor, or +a design you control down to the pixel. Chronicle gives you a choice of three +themes and no way to write a fourth. -```bash -chronicle init -c docs -``` +## What you get -### 2. Start the development server +Write MDX and Chronicle handles the rest of a docs site: navigation built from +your folders, full-text search, a table of contents per page, breadcrumbs, +previous and next links, dark mode, social cards, a sitemap, and redirects for +URLs you have moved. -```bash -chronicle dev -``` +The pieces you will touch most often: -Your docs site is now running at [http://localhost:3000](http://localhost:3000). +| You want to | Go to | +|---|---| +| Get a site running | [Quick start](/docs/quick-start) | +| Write and organise pages | [Writing docs](/docs/writing/pages) | +| Publish a second version | [Versioned documentation](/docs/guides/versioning) | +| Publish an API reference | [API reference](/docs/guides/api-reference) | +| Look up a config key | [chronicle.yaml](/docs/reference/config) | +| Put it on the internet | [Deploy](/docs/deploy/hosting) | -### 3. Add content +## How a project is laid out -Create `.mdx` files in your content directory. Each file becomes a page. Use folders to create nested navigation. +One config file, one content folder: ``` my-docs/ -├── chronicle.yaml +├── chronicle.yaml # the whole site config ├── content/ -│ ├── index.mdx -│ ├── getting-started.mdx -│ └── guides/ -│ ├── installation.mdx -│ └── configuration.mdx +│ └── docs/ # a content directory +│ ├── index.mdx # → /docs +│ └── guides/ +│ └── setup.mdx # → /docs/guides/setup └── .output/ # build output, gitignored ``` -### 4. Build for production - -```bash -chronicle build -chronicle start -``` - -Or use the combined command: - -```bash -chronicle serve -``` - -## Project Structure - -A minimal Chronicle project looks like: - -``` -my-docs/ -├── chronicle.yaml # Site configuration -├── content/ -│ ├── index.mdx # Home page -│ └── guides/ -│ └── setup.mdx # Nested page at /guides/setup -└── .output/ # Build output, gitignored -``` - -All configuration is done through `chronicle.yaml`. No additional config files needed. +Files become URLs. Folders become groups in the sidebar. See +[Project structure](/docs/structure) for how the pieces fit together. -## Next Steps +## Next -- [CLI Commands](/docs/cli) — All available CLI commands and flags -- [Configuration](/docs/configuration) — Full `chronicle.yaml` reference -- [Frontmatter](/docs/frontmatter) — Page-level metadata options -- [Components](/docs/components) — MDX components and admonitions -- [Themes](/docs/themes) — Available themes and customization +Start with the [quick start](/docs/quick-start) — it takes about two minutes and +ends with a site running on your machine. diff --git a/docs/content/docs/quick-start.mdx b/docs/content/docs/quick-start.mdx new file mode 100644 index 00000000..92fba3b7 --- /dev/null +++ b/docs/content/docs/quick-start.mdx @@ -0,0 +1,91 @@ +--- +title: Quick start +description: Install Chronicle and get a documentation site running on your machine. +order: 2 +--- + +This gets you from nothing to a running site. It takes about two minutes. + +## Install + +```bash +bun add @raystack/chronicle +``` + +## Create a project + +```bash +chronicle init +``` + +That writes three things: + +- `chronicle.yaml` — your site config +- `content/docs/` — a content directory with a sample `index.mdx` +- `.gitignore` — entries for `node_modules`, `dist` and `.output` + +Already have a docs folder you want to keep? Point `init` at it instead of +letting it make a new one: + +```bash +chronicle init -c docs +``` + +Nothing is overwritten. If the directory already has files, the sample page is +skipped. See [Move an existing docs site](/docs/guides/migrate) for the longer +version. + +## Start the dev server + +```bash +chronicle dev +``` + +Your site is at [http://localhost:3000](http://localhost:3000). Edit a page and +the browser updates without a reload. + +## Write a page + +Create `content/docs/hello.mdx`: + +```mdx +--- +title: Hello +description: My first page. +order: 2 +--- + +This is a page. It lives at `/docs/hello`. + +## A section + +Sections use `##` and below. The heading above the article comes from `title`, +so a page never needs its own `#`. +``` + +Reload and it appears in the sidebar, in search, and in the previous and next +links. Nothing else to register. + +## Build for production + +```bash +chronicle build +chronicle start +``` + +Or do both in one step while you are trying things out: + +```bash +chronicle serve +``` + +`build` writes to `.output/`. `start` serves what `build` produced — it does not +build for you, so run them in that order. See +[Build and serve](/docs/deploy/build) for what the output contains and how +presets change it. + +## Next + +- [Project structure](/docs/structure) — what each file and folder is for +- [Pages and frontmatter](/docs/writing/pages) — every field a page can set +- [chronicle.yaml](/docs/reference/config) — the full config reference diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/reference/cli.mdx similarity index 82% rename from docs/content/docs/cli.mdx rename to docs/content/docs/reference/cli.mdx index 3d615a27..3ea28eb0 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/reference/cli.mdx @@ -1,11 +1,9 @@ --- -title: CLI Commands -description: Chronicle CLI commands reference. -order: 2 +title: CLI commands +description: Every Chronicle command and flag. +order: 3 --- -# CLI Commands - Chronicle provides a CLI to initialize, develop, build, and serve your documentation site. ## init @@ -54,7 +52,7 @@ chronicle build [options] |------|-------------|---------| | `--content ` | Content directory | `content` | | `--config ` | Path to `chronicle.yaml` | `./chronicle.yaml` | -| `--preset ` | Deploy preset (`vercel`, `cloudflare`, `node-server`) | — | +| `--preset ` | Deploy preset. See [`preset`](/docs/reference/config) for the values | — | ## start @@ -84,9 +82,9 @@ chronicle serve [options] | `--config ` | Path to `chronicle.yaml` | `./chronicle.yaml` | | `-p, --port ` | Port number | `3000` | | `--host ` | Host address | `localhost` | -| `--preset ` | Deploy preset (`vercel`, `cloudflare`, `node-server`) | — | +| `--preset ` | Deploy preset. See [`preset`](/docs/reference/config) for the values | — | -## Resolution Order +## Resolution order CLI flags take precedence over `chronicle.yaml` values, which take precedence over defaults. @@ -95,3 +93,8 @@ CLI flags take precedence over `chronicle.yaml` values, which take precedence ov | Content directory | `--content` | `content` | `content` | | Deploy preset | `--preset` | `preset` | — | | Config path | `--config` | — | `./chronicle.yaml` | + +`start` serves what `build` produced. It does not build, so run `build` first — +or use `serve`, which does both. + +See [Build and serve](/docs/deploy/build) for what each preset writes. diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/reference/config.mdx similarity index 78% rename from docs/content/docs/configuration.mdx rename to docs/content/docs/reference/config.mdx index 520b575c..f7ce2822 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/reference/config.mdx @@ -1,30 +1,16 @@ --- -title: Configuration -description: Full chronicle.yaml configuration reference. -order: 3 +title: chronicle.yaml +description: Every key the site config accepts. +order: 1 --- -# Configuration - All site configuration lives in a single `chronicle.yaml` file in your project root. The config is validated using Zod — invalid fields produce clear errors at startup. -## Project layout - -``` -my-docs-site/ -├── chronicle.yaml -├── content/ ← latest -│ ├── docs/ -│ └── dev/ -└── versions/ ← only if versions: is declared - ├── v2/ - │ └── docs/ - └── v1/ - ├── docs/ - └── dev/ -``` +Only `site.title` and `content` are required. Everything else has a default or +is optional. -Content dirs declared in top-level `content:` are resolved under `content//` for the latest version; each `versions[].content[].dir` is resolved under `versions///`. +For how the files on disk map to URLs, see +[Project structure](/docs/structure). ## Full example @@ -216,28 +202,57 @@ versions: | `content` | `{dir, label}[]` | Content dirs for this version. Entries may rename, reorder, or omit top-level content dirs. | | `api` | `ApiConfig[]` | Version-scoped API specs, rendered at `//apis/...`. Same shape as top-level `api:`. | +See [Versioned documentation](/docs/guides/versioning) for how to cut a new version. + ### preset -Optional deploy preset. Can be overridden by `--preset`. +Deploy target. Decides what `chronicle build` produces and where it writes. +Overridden by `--preset`. ```yaml -preset: vercel # vercel, cloudflare, or node-server +preset: vercel ``` +| Value | Output | +|-------|--------| +| — (unset) | A Node server in `.output/` | +| `node-server` | A Node server in `.output/` | +| `vercel` | A Vercel function build in `.vercel/output/` | +| `cloudflare` | A Cloudflare Workers build in `.output/` | +| `static` | Pre-rendered HTML in `.output/public/` | +| `vercel-static` | Pre-rendered HTML in `.vercel/output/` | +| `cloudflare-pages` | Pre-rendered HTML in `.output/public/` | +| `github-pages` | Pre-rendered HTML in `.output/public/` | + +The four static presets pre-render every page at build time and need no server +to run. See [Deploy](/docs/deploy/hosting). + ### logo -Logo with theme-aware variants. +The mark shown in the sidebar, used as the favicon, and drawn on social cards. ```yaml logo: - light: ./logo-light.png - dark: ./logo-dark.png + light: /logo.svg + dark: /logo-dark.svg ``` | Field | Type | Description | |-------|------|-------------| -| `light` | `string` | Logo path or URL for light theme | -| `dark` | `string` | Logo path or URL for dark theme | +| `light` | `string` | Path or URL used on a light background | +| `dark` | `string` | Path or URL used on a dark background | + +Files live in `public/`, so `public/logo.svg` is written as `/logo.svg`. Setting +one is enough — the other falls back to it, which is what you want for a mark +that reads on either background. + +Set neither and every theme shows the site's first letter in a tinted box. It is +deliberately plain, so a site without a logo looks unfinished rather than +borrowed. + +The same image becomes the favicon, replacing `public/favicon.ico` and +`public/favicon.svg`. Those two are still used when no logo is set, so an +explicit favicon keeps working. ### theme @@ -251,9 +266,25 @@ theme: | Field | Type | Description | Default | |-------|------|-------------|---------| | `name` | `'default' \| 'paper' \| 'fanfold'` | Theme to use | `default` | -| `colors` | `Record` | Custom color overrides | — | +| `colors` | `Record` | Colour overrides, keyed by design token | — | -See [Themes](/docs/themes) for details on each theme. +```yaml +theme: + name: default + colors: + background-base-primary: "#fbfaf8" + foreground-accent-primary: "#0a3793" +``` + +Each key names an Apsara colour token, and the `--rs-color-` prefix is added for +you. Write the whole custom property yourself — `--paper-ink` — to reach one +outside that family. + +A value applies in both light and dark, since the config holds one value per +token. Leave a token alone if the two themes should differ. + +See [Choosing a theme](/docs/themes) for which one to pick and +[Theme options](/docs/themes/options) for what each gives you. ### navigation @@ -318,7 +349,7 @@ Any query string already on `href` is preserved, and a `utm_*` param you set you ### search -Search functionality powered by Fumadocs. Automatically scoped to the active version. +Full-text search over titles, headings and page bodies. Scoped to the active version. ```yaml search: @@ -333,6 +364,8 @@ search: When enabled, search is accessible via the navbar button or keyboard shortcut `Cmd+K` / `Ctrl+K`. Active version comes from the URL; switching versions scopes the index. +See [Search](/docs/guides/search) for what gets indexed. + ### api OpenAPI specification configuration at the top level applies to the latest version (served at `/apis/...`). Version-scoped specs live under each `versions[].api`. @@ -363,6 +396,8 @@ api: | `auth.header` | `string` | Header name for auth token | | `auth.placeholder` | `string` | Placeholder text in auth input | +See [API reference](/docs/guides/api-reference) for what readers get from a spec. + ### redirects URL redirects for migrating old routes to new ones. Checked before all other routes. @@ -384,6 +419,8 @@ redirects: Use `permanent: true` when the old URL should never be used again — search engines and browsers will cache the redirect. +See [Links and redirects](/docs/writing/links) for when to use each status. + ### analytics Analytics integration for tracking page views. @@ -400,6 +437,8 @@ analytics: | `enabled` | `boolean` | Enable/disable analytics | `false` | | `googleAnalytics.measurementId` | `string` | Google Analytics measurement ID | — | +See [Monitoring](/docs/deploy/monitoring) for what else a running site reports. + ### authors Optional registry of the people who write your docs. Pages reference an entry by @@ -430,9 +469,11 @@ authors: [jane] A frontmatter string that matches no key is still valid — it renders as a plain name, so occasional contributors need no registry entry. See -[Frontmatter](/docs/frontmatter) for the field itself, and browse the people +[Frontmatter](/docs/reference/frontmatter) for the field itself, and browse the people writing a site at `/authors`. +See [Authors and bylines](/docs/writing/authors) for how bylines and author pages work. + ### telemetry Prometheus metrics export via OpenTelemetry. Served on a separate port. @@ -452,6 +493,8 @@ telemetry: Metrics are available at `http://localhost:/metrics` in Prometheus exposition format. +See [Monitoring](/docs/deploy/monitoring) for the health and readiness endpoints. + ## Defaults When `chronicle.yaml` is missing or fields are omitted, these defaults apply: diff --git a/docs/content/docs/frontmatter.mdx b/docs/content/docs/reference/frontmatter.mdx similarity index 71% rename from docs/content/docs/frontmatter.mdx rename to docs/content/docs/reference/frontmatter.mdx index 38a811ae..93671112 100644 --- a/docs/content/docs/frontmatter.mdx +++ b/docs/content/docs/reference/frontmatter.mdx @@ -1,14 +1,13 @@ --- -title: Frontmatter -description: Page-level metadata options for MDX files. -order: 4 +title: Frontmatter fields +description: Every field a page can set in its frontmatter. +order: 2 --- -# Frontmatter - -Every MDX file supports YAML frontmatter at the top of the file for page-level configuration. +Every field a page can set. Only `title` is required. -## Example +For how to write a page day to day, see +[Pages and frontmatter](/docs/writing/pages). ```mdx --- @@ -21,21 +20,40 @@ authors: - Jane Doe --- -# Getting Started - Your content here... ``` +## Fields at a glance + +| Field | Type | What it does | +|---|---|---| +| `title` | `string` | Heading, sidebar label, browser tab. Falls back to `Untitled` | +| `description` | `string` | Line under the heading, meta description, social card | +| `order` | `number` | Sidebar position. Lower first. Decimals allowed | +| `draft` | `boolean` | `true` removes the page from the site | +| `short` | `string` | Shorter sidebar label. Read by `fanfold` | +| `identifiers` | `string[]` | Extra header lines. Read by `fanfold` | +| `icon` | `string` | Sidebar icon | +| `authors` | `string \| string[]` | Byline, author pages, structured data | +| `lastModified` | `string` | Date the page states as its last update | + ## Fields ### title -**Required.** The page title used in navigation, browser tab, and page heading. +The page title used in navigation, the browser tab, and the heading above the +article. A page without one renders as `Untitled`, so treat it as required even +though nothing enforces it. ```yaml title: Installation Guide ``` +Every theme prints this title above the article, so start your content with the +first section rather than repeating the title as a `#` heading. A page that opens +with `# Installation Guide` shows the same words twice. Use `##` and below for +sections — those are what the table of contents lists. + ### short Optional short label for the sidebar. Use it when the full title is too long for @@ -140,19 +158,6 @@ authors: - Sam Patel ``` -### draft - -Set `draft: true` to keep a page out of the site. It is dropped from the -navigation tree, so it does not appear in the sidebar, in breadcrumbs, in search, -or in the previous/next links. - -```yaml -draft: true -``` - -The file stays where it is, so this is the way to leave a page in the repository -while it is still being written. - A single author can be written without the list: ```yaml @@ -167,7 +172,7 @@ and on the generated social card. Every author also gets a page at A byline shows two authors at most, collapsing the rest into a `+N` counter that names them on hover. The avatar beside each name is drawn from the author's initials unless the author has an `avatar` in the -[authors registry](/docs/configuration). +[authors registry](/docs/reference/config). Registry keys work here too, and bring the author's bio, avatar, and profile link along with them: @@ -176,38 +181,20 @@ link along with them: authors: [jane] ``` -## Navigation Ordering - -Sidebar navigation is determined by: - -1. **Frontmatter `order`** — Pages sorted by `order` value (ascending) -2. **Decimal values** — Use `1`, `1.5`, `2` for fine-grained positioning -3. **Folders** — Directories become collapsible groups, auto-named from the folder name (capitalized) -4. **No `meta.json` required** — Ordering is entirely frontmatter-based +### draft -### Example structure +Set `draft: true` to keep a page out of the site. It is dropped from the +navigation tree, so it does not appear in the sidebar, in breadcrumbs, in search, +or in the previous/next links. +```yaml +draft: true ``` -content/ -├── index.mdx # order: 1 -├── quickstart.mdx # order: 2 -├── guides/ -│ ├── setup.mdx # order: 1 -│ └── advanced.mdx # order: 2 -└── reference/ - ├── cli.mdx # order: 1 - └── config.mdx # order: 2 -``` -This produces sidebar navigation: +The file stays where it is, so this is the way to leave a page in the repository +while it is still being written. + +## Where ordering is explained -``` -Home (order: 1) -Quickstart (order: 2) -▸ Guides - Setup (order: 1) - Advanced (order: 2) -▸ Reference - CLI (order: 1) - Config (order: 2) -``` +`order`, `short` and folder `meta.json` all shape the sidebar together. See +[Navigation](/docs/writing/navigation). diff --git a/docs/content/docs/reference/meta.json b/docs/content/docs/reference/meta.json new file mode 100644 index 00000000..e79619ed --- /dev/null +++ b/docs/content/docs/reference/meta.json @@ -0,0 +1 @@ +{ "title": "Reference", "order": 7 } diff --git a/docs/content/docs/reference/routes.mdx b/docs/content/docs/reference/routes.mdx new file mode 100644 index 00000000..802cc3a5 --- /dev/null +++ b/docs/content/docs/reference/routes.mdx @@ -0,0 +1,125 @@ +--- +title: Generated routes +description: The URLs Chronicle serves that you never wrote a page for. +order: 4 +--- + +Beyond your pages, a Chronicle site answers at a set of URLs it generates +itself. None of them need configuring. They are listed here so you know what is +public and what you can point other tools at. + +## For search engines + +### /sitemap.xml + +Every page on the site, for crawlers. + +Absolute URLs need to know the site's address, so set `url` in the config or the +sitemap has nothing to build them from: + +```yaml +url: https://docs.example.com +``` + +On a versioned site, older versions are included alongside the current one. + +### /robots.txt + +Allows everything, and points at the sitemap: + +``` +User-agent: * +Allow: / + +Sitemap: https://docs.example.com/sitemap.xml +``` + +The `Sitemap` line appears only when `url` is set. + +## For AI tools + +### The `.md` URL of any page + +Every page answers at its own path plus `.md`, returning the raw markdown with +the frontmatter stripped: + +```bash +curl https://docs.example.com/docs/quick-start.md +``` + +API endpoints do the same, and generate a full markdown document — parameters, +examples, responses and a cURL command: + +```bash +curl https://docs.example.com/apis/petstore/findPetsByStatus.md +``` + +This is what the **Open in AI** menu on each page copies, and what it hands to +ChatGPT or Claude when a reader picks one. + +### /llms.txt + +An index of the site in the format AI tools look for: the site title, its +description, and a link to every page's markdown URL. + +``` +# Chronicle + +Config-driven documentation framework + +- [Introduction](/docs.md) +- [Quick start](/docs/quick-start.md) +``` + +Nothing to turn on. Every page you add appears here. + +## For social cards + +### /og + +Renders a social card as a PNG — the image people see when a page is shared in +Slack, on a social network, or in a chat app. + +Pages point at this themselves in their meta tags, so you rarely call it +directly. It takes what it draws from the query string: + +| Parameter | What it draws | +|---|---| +| `title` | The headline. Falls back to the site title | +| `description` | A line under the headline | +| `authors` | A `By …` line | + +The card also carries your `logo`, or the site's first letter when none is set. +See [`logo`](/docs/reference/config). + +Cards are only referenced when `url` is set, because a social network needs an +absolute URL to fetch the image from. + +## For the site itself + +These serve the running site's own front end. They are documented because they +are reachable, not because you need them. + +| Route | What it returns | +|---|---| +| `/api/search` | Search results for a query, scoped to a version | +| `/api/page` | A page's metadata, fetched when navigating | +| `/api/specs` | The parsed OpenAPI specs for a version | +| `/api/image` | A resized, re-encoded content image | +| `/api/authors` | The author index | +| `/api/health` | Liveness. Always `200` | +| `/api/ready` | Readiness. `200` once the search index is built, `503` before | + +`/api/health` and `/api/ready` are the two worth wiring into a deployment. See +[Monitoring](/docs/deploy/monitoring). + +## In a static build + +A static build has no server, so everything that would be computed per request +is written out as a file during the build instead. `sitemap.xml`, `robots.txt`, +`llms.txt`, every `.md` file and every social card are all generated then. + +The `/api/*` routes have no equivalent. Search reads an index file downloaded +into the browser, images are resized during the build rather than on demand, and +the health, readiness and API proxy routes do not exist. See +[Build and serve](/docs/deploy/build). diff --git a/docs/content/docs/structure.mdx b/docs/content/docs/structure.mdx new file mode 100644 index 00000000..f821fee2 --- /dev/null +++ b/docs/content/docs/structure.mdx @@ -0,0 +1,117 @@ +--- +title: Project structure +description: What each file and folder in a Chronicle project is for. +order: 3 +--- + +A Chronicle project is a config file and a folder of MDX. Everything else is +either static files you supply or output Chronicle writes. + +``` +my-docs/ +├── chronicle.yaml # the whole site config +├── content/ # pages for the current version +│ ├── docs/ +│ └── dev/ +├── versions/ # only if you declare versions: +│ ├── v2/ +│ │ └── docs/ +│ └── v1/ +│ ├── docs/ +│ └── dev/ +├── public/ # static files served as-is +├── .output/ # build output, gitignored +└── .cache/ # optimized images, gitignored +``` + +## chronicle.yaml + +Every setting lives here. There is no second config file and no JavaScript +config. The file is checked against a schema when the server starts, so a typo +or a wrong type fails immediately with a message naming the field rather than +breaking a page later. + +Only two keys are required: `site.title` and `content`. See +[chronicle.yaml](/docs/reference/config) for all seventeen. + +## content + +Pages for the current version. This folder does not become a URL segment — the +directories inside it do. + +Each directory you name in `content:` is a **content directory**, a top-level +section of the site with its own navigation: + +```yaml +content: + - dir: docs + label: Docs + - dir: dev + label: Dev Docs +``` + +That maps `content/docs/` to `/docs/...` and `content/dev/` to `/dev/...`. A site +with two or more content directories gets a switcher for moving between them — +see [Multiple content sections](/docs/guides/sections). + +### Files become URLs + +``` +content/docs/index.mdx → /docs +content/docs/hello.mdx → /docs/hello +content/docs/guides/setup.mdx → /docs/guides/setup +``` + +`index.mdx` is the page for the directory holding it. `readme.mdx` works the same +way, which is handy when the folder is also read on GitHub. + +Folders become groups in the sidebar. See +[Navigation](/docs/writing/navigation) for naming and ordering them. + +## versions + +Only present if you declare `versions:` in the config. Each version is a folder, +and the folder name doubles as the URL prefix: + +``` +versions/v1/docs/index.mdx → /v1/docs +``` + +The current version stays in `content/` with no prefix. See +[Versioned documentation](/docs/guides/versioning). + +## public + +Static files, served from the root of the site with their paths unchanged. +`public/logo.svg` is available at `/logo.svg`, which is what a `logo:` entry in +the config points at. + +Use it for logos, favicons, and downloads. Images you reference from a page are +better placed next to the page — see [Images](/docs/writing/images). + +## .output + +What `chronicle build` writes. Gitignore it. + +For a server build it holds a runnable server that `chronicle start` serves. For +a static build it holds `.output/public`, a folder of HTML and assets you can +upload anywhere. Building with the `vercel` preset writes to `.vercel/output` +instead. See [Build and serve](/docs/deploy/build). + +## .cache + +Resized and re-encoded images, kept between restarts so the work is done once. +Gitignore it. See [Images](/docs/writing/images). + +## Using it inside a monorepo + +Chronicle takes the config path as a flag, so the docs site does not have to sit +at the repository root: + +```bash +chronicle dev --config docs/chronicle.yaml +``` + +Paths inside the config resolve relative to `chronicle.yaml`, not to where you +ran the command. A `spec: ./openapi.yaml` next to the config is found whichever +directory you start from. diff --git a/docs/content/docs/themes/index.mdx b/docs/content/docs/themes/index.mdx new file mode 100644 index 00000000..a2626617 --- /dev/null +++ b/docs/content/docs/themes/index.mdx @@ -0,0 +1,79 @@ +--- +title: Choosing a theme +description: Three built-in themes, and which one suits your docs. +order: 1 +--- + +Chronicle ships three themes. Set one in `chronicle.yaml`: + +```yaml +theme: + name: default +``` + +You cannot write a fourth, so pick the one whose shape fits your material. You +can recolour whichever you pick — see [Theme options](/docs/themes/options). + +## Which one + +| Theme | Shape | Reach for it when | +|---|---|---| +| `default` | Sidebar, content, table of contents | Reference docs people navigate and search | +| `paper` | One column, reading progress | Guides and long explanations people read start to finish | +| `fanfold` | Line-printer paper, monospace throughout | Systems and protocol docs, where a printed look fits the material | + +### default + +The layout most documentation uses, and the right first choice. A navigation +tree on the left, the page in the middle, its headings on the right. Readers +already know how to use it, and nothing about it will surprise them. + +Pick it when people arrive knowing what they want and need to find it. + +### paper + +A single column at a reading width, with a progress indicator down the side and +no table of contents competing for attention. Typography is tuned for long +stretches of prose. + +Pick it when your docs are more explanation than reference — a handbook, a set +of guides, something read in order rather than searched. + +### fanfold + +Continuous-form line printer paper: tractor-feed strips down both edges, faint +zebra banding behind the text, monospace type, and a printed header block on +every page carrying the breadcrumb trail and a page counter. + +It reads two frontmatter fields the others ignore. `short` gives a page a code +for the narrow rail, and `identifiers` prints extra lines in the header — the +standard a page implements, its package, its command. + +Pick it for material that is already technical and enumerated: protocols, wire +formats, command references. It is a strong look, and it is the wrong choice for +a friendly product guide. + +## Trying them + +Switching costs one line, and no page content has to change: + +```yaml +theme: + name: paper +``` + +Restart the dev server and click through your five longest pages. A theme that +suits your material is obvious quickly, and so is one that does not. + +Two fields are only read by `fanfold`, so a page that sets `short` or +`identifiers` loses nothing by moving to another theme — the fields are ignored, +not broken. + +## Dark mode + +All three do light and dark, following the reader's system preference with a +toggle to override it. There is nothing to configure. + +Check both when you pick a theme, and again if you set your own colours — a +look that works in one is not guaranteed to work in the other, and half your +readers will see each. diff --git a/docs/content/docs/themes/meta.json b/docs/content/docs/themes/meta.json new file mode 100644 index 00000000..93a3a105 --- /dev/null +++ b/docs/content/docs/themes/meta.json @@ -0,0 +1 @@ +{ "title": "Themes", "order": 6 } diff --git a/docs/content/docs/themes.mdx b/docs/content/docs/themes/options.mdx similarity index 68% rename from docs/content/docs/themes.mdx rename to docs/content/docs/themes/options.mdx index 238a335d..a4215a49 100644 --- a/docs/content/docs/themes.mdx +++ b/docs/content/docs/themes/options.mdx @@ -1,19 +1,18 @@ --- -title: Themes -description: Available themes and customization options. -order: 7 +title: Theme options +description: What each built-in theme gives you, and how to tune its colours. +order: 2 --- -# Themes - -Chronicle ships with three built-in themes. Set the theme in your `chronicle.yaml`: +What each theme gives you. For help picking one, see +[Choosing a theme](/docs/themes). ```yaml theme: name: default ``` -## Default Theme +## Default theme A traditional documentation layout with three-column design. @@ -37,7 +36,7 @@ theme: - Breadcrumb navigation - Active heading tracking in table of contents -## Paper Theme +## Paper theme A book-style layout optimized for long-form reading. @@ -59,7 +58,7 @@ theme: - Optimized typography for long content - Light and dark mode -## Fanfold Theme +## Fanfold theme A continuous-form line printer look — tractor-feed strips down both edges, faint zebra banding behind the page, and monospace type throughout. @@ -78,7 +77,7 @@ theme: ### Features - Header block prints the breadcrumb trail and a page counter, then whatever the - page gives as [`identifiers`](/docs/frontmatter) — falling back to the site + page gives as [`identifiers`](/docs/reference/frontmatter) — falling back to the site name and path - Page title set in a dot-matrix face that steps down in size as titles get longer - Everything on the sheet is held to one 80-column measure, so prose, tables, @@ -90,7 +89,7 @@ theme: prose in it has its columns measured at build time, and folds that column under each row behind a toggle, so the paragraph reads at the full width of the sheet instead of a narrow column -- Uses a page's [`short`](/docs/frontmatter) label in the rail and the header trail +- Uses a page's [`short`](/docs/reference/frontmatter) label in the rail and the header trail - Its own landing page: a masthead over a register of every section - Light and dark mode @@ -107,3 +106,28 @@ Set it in `chronicle.yaml`: latest: landing: true ``` + +## Recolouring a theme + +Every theme draws from a set of colour tokens. Override any of them under +`theme.colors`: + +```yaml +theme: + name: default + colors: + background-base-primary: "#fbfaf8" + foreground-accent-primary: "#0a3793" + border-base-primary: "#e6e1d8" +``` + +The `--rs-color-` prefix is added for you, so name the token on its own. To +reach a variable outside that family — the ones a theme defines for itself, like +`--paper-ink` or `--fan-rule` — write the whole property name instead. + +One value covers both light and dark. If a token needs to differ between them, +leave it out and let the theme decide. + +Values have to look like colours: a hex code, `rgb()`, `hsl()`, `color-mix()`, a +named colour, or a `var()` reference. Anything else is ignored rather than +written into the page. diff --git a/docs/content/docs/writing/authors.mdx b/docs/content/docs/writing/authors.mdx new file mode 100644 index 00000000..2ea7aadb --- /dev/null +++ b/docs/content/docs/writing/authors.mdx @@ -0,0 +1,85 @@ +--- +title: Authors and bylines +description: Credit the people who write your docs, and give each of them a page. +order: 6 +--- + +Naming an author puts a byline on the page, credits them in the page's structured +data and on its social card, and gives them a page listing everything they wrote. + +## Crediting a page + +Add `authors` to the page's frontmatter. Each entry is a plain string — either +`Name ` or just a name: + +```yaml +authors: + - Jane Doe + - Sam Patel +``` + +A single author does not need the list: + +```yaml +authors: Jane Doe +``` + +That is all you need. Nothing has to be registered first, which keeps the door +open for an occasional contributor. + +## The byline + +The byline shows two names at most. Any more collapse into a `+N` counter that +names the rest on hover, so a page with six authors does not push its own title +down the screen. + +Each name carries an avatar. Without a picture to use, it is drawn from the +author's initials. + +## The authors registry + +Writing the same name and email on forty pages gets old, and it means forty +places to edit when something changes. Put the details in `chronicle.yaml` +instead: + +```yaml +authors: + jane: + name: Jane Doe + bio: Writes about distributed systems. + avatar: /team/jane.png + url: https://github.com/jane + email: jane@example.com +``` + +Then reference the key from a page: + +```yaml +authors: [jane] +``` + +The bio, avatar and profile link come along with it. See +[`authors`](/docs/reference/config) for the full field list. + +A frontmatter string that matches no key is still valid — it renders as a plain +name. So you can register the people who write often and spell out the rest. + +## Author pages + +Every author gets a page at `/authors/` listing everything they wrote, and +every byline name links to it. The site's full list is at `/authors`. + +An author's `url` and `email` are shown on their page rather than in the byline, +which keeps the byline to names and the contact details in one predictable place. + +## Where else authors show up + +| Place | What it uses | +|---|---| +| The byline | Name and avatar | +| The author's page | Name, bio, avatar, `url`, `email` | +| `Article` structured data | Author names, for search engines | +| The social card | Author names, under the title | + +The social card is the image people see when a page is shared. See +[Generated routes](/docs/reference/routes) for how it is made. diff --git a/docs/content/docs/components.mdx b/docs/content/docs/writing/components.mdx similarity index 99% rename from docs/content/docs/components.mdx rename to docs/content/docs/writing/components.mdx index 84f3633a..434a3343 100644 --- a/docs/content/docs/components.mdx +++ b/docs/content/docs/writing/components.mdx @@ -1,11 +1,9 @@ --- title: Components description: MDX components and admonitions supported in Chronicle. -order: 6 +order: 3 --- -# Components - Chronicle provides built-in MDX components that enhance standard markdown with interactive elements and styled content. ## Callout @@ -243,7 +241,7 @@ Images support both local and external sources: Links are automatically handled: ```mdx -[Internal link](/docs/configuration) +[Internal link](/docs/reference/config) [External link](https://github.com) [Anchor link](#section) ``` diff --git a/docs/content/docs/image-optimization.mdx b/docs/content/docs/writing/images.mdx similarity index 71% rename from docs/content/docs/image-optimization.mdx rename to docs/content/docs/writing/images.mdx index a3d82f5a..35b6f9d4 100644 --- a/docs/content/docs/image-optimization.mdx +++ b/docs/content/docs/writing/images.mdx @@ -1,14 +1,40 @@ --- -title: Image Optimization -description: Automatic image optimization with on-demand resizing and format conversion. -order: 5 +title: Images +description: How images resolve, and the resizing and format conversion Chronicle does for you. +order: 4 --- -# Image Optimization +## Adding an image -Chronicle automatically optimizes content images via an on-demand `/api/image` endpoint. Images are resized, converted to modern formats (WebP/AVIF), and cached on disk. +Put the file next to the page that uses it and reference it relatively: -## How It Works +```mdx +![Architecture overview](./architecture.png) +``` + +A path starting with `/` is resolved from the root of the content directory +instead, which is what you want for an image several pages share: + +```mdx +![Logo](/shared/logo.svg) +``` + +Either way Chronicle rewrites the URL at build time and appends a hash of the +file's contents. That hash is why an image can be cached forever by a browser and +still update the moment you change the file. + +Images in `public/` are different — they are served exactly as you put them +there, with no rewriting and no optimization. Use `public/` for a favicon or a +download, and keep content images next to their pages. + +## Optimization + +Everything below happens on its own. There is nothing to turn on. + +Content images are resized, re-encoded to a modern format, and cached on disk, +through an on-demand `/api/image` endpoint. + +## How it works 1. The remark plugin rewrites all image URLs to route through `/api/image` 2. On first request, the image is resized and converted based on browser support diff --git a/docs/content/docs/writing/links.mdx b/docs/content/docs/writing/links.mdx new file mode 100644 index 00000000..ae9a4203 --- /dev/null +++ b/docs/content/docs/writing/links.mdx @@ -0,0 +1,98 @@ +--- +title: Links and redirects +description: How links between pages resolve, and how to keep old URLs working after you move one. +order: 5 +--- + +## Linking between pages + +Use the page's URL, starting from the root: + +```mdx +See [Navigation](/docs/writing/navigation) for ordering. +``` + +Internal links are resolved when the page is built, so a link to a page that +does not exist is caught then rather than becoming a 404 for a reader. Internal +links also navigate without a full page load. + +Relative links work too, and are resolved against the current page: + +```mdx +See [Navigation](./navigation) for ordering. +``` + +Prefer absolute paths for anything that crosses a folder. A relative link breaks +quietly when you move the page holding it; an absolute one keeps working. + +### Linking to a section + +Add the heading's slug. Slugs are the heading text, lowercased, with spaces +turned into hyphens: + +```mdx +[Ordering pages](/docs/writing/navigation#ordering-pages) +``` + +### External links + +Write them as normal. They open in a new tab. + +```mdx +[Nitro](https://nitro.build) +``` + +## Redirects + +Move or rename a page and its old URL stops working. Add a redirect so anyone +holding the old link — a bookmark, a blog post, a search result — still lands in +the right place. + +```yaml +redirects: + - from: /docs/old-page + to: /docs/writing/pages + - from: /legacy/api + to: /apis + permanent: true +``` + +Redirects are checked before every other route, so they win over a real page at +the same path. + +| Field | What it does | +|---|---| +| `from` | The old path | +| `to` | Where to send it | +| `permanent` | `true` sends 308, `false` (default) sends 307 | + +### Which status to use + +Use `permanent: true` when the old URL is never coming back. Browsers and search +engines cache a 308 hard, so a mistake is expensive to undo — a reader whose +browser cached it will keep being redirected after you remove the rule. + +Use the default 307 while you are still deciding. Nothing is cached, so you can +change your mind. + +### A worked example + +Say you split one page into a folder of three. The old URL was `/docs/config`: + +```yaml +redirects: + - from: /docs/config + to: /docs/reference/config + permanent: true +``` + +Anyone following an old link arrives at the new page. Meanwhile, update the +links inside your own pages to point at the new URL directly — a redirect is for +links you do not control, not a substitute for fixing your own. + +## Markdown links to your own pages + +Every page also answers at a `.md` URL that returns its raw source. This is what +the "Open in AI" menu copies, and what an AI tool reads when it follows +`llms.txt`. You do not have to do anything to get it. See +[Generated routes](/docs/reference/routes). diff --git a/docs/content/docs/writing/meta.json b/docs/content/docs/writing/meta.json new file mode 100644 index 00000000..14b976ad --- /dev/null +++ b/docs/content/docs/writing/meta.json @@ -0,0 +1 @@ +{ "title": "Writing docs", "order": 4 } diff --git a/docs/content/docs/writing/navigation.mdx b/docs/content/docs/writing/navigation.mdx new file mode 100644 index 00000000..13a14ce7 --- /dev/null +++ b/docs/content/docs/writing/navigation.mdx @@ -0,0 +1,140 @@ +--- +title: Navigation +description: Order pages, name folders, and shape the sidebar with meta.json. +order: 2 +--- + +The sidebar is built from your folders. You do not write a navigation file — you +name and order what is already there. + +## Ordering pages + +Add `order` to a page's frontmatter. Lower numbers come first. + +```mdx +--- +title: Installation +order: 1 +--- +``` + +Pages with no `order` come after every page that has one, so you can order the +important pages and leave the rest. + +### Slotting a page between two others + +`order` takes decimals. This is the whole reason it does — you can insert a page +without renumbering its neighbours: + +``` +introduction.mdx order: 1 +installation.mdx order: 1.5 ← new page, lands between the two +configuration.mdx order: 2 +``` + +## Folders become groups + +A folder is a group in the sidebar. Its name is derived from the folder name, +capitalised: + +``` +content/docs/ +├── index.mdx +├── quick-start.mdx +└── guides/ + ├── setup.mdx + └── advanced.mdx +``` + +``` +Quick start +▸ Guides + Setup + Advanced +``` + +Pages and folders sort against each other on one scale, so where a folder lands +depends on its `order`, not on it being a folder. Without one it goes last. + +## meta.json + +Drop a `meta.json` in a folder to control the folder itself. The folder name is +only a fallback. + +```json +{ + "title": "Getting started", + "order": 2 +} +``` + +| Field | What it does | +|---|---| +| `title` | The group label. Overrides the capitalised folder name | +| `order` | Where the group sits. Shares one scale with page `order` | +| `pages` | Explicit page order, by filename without the extension | +| `root` | `true` makes this folder a navigation boundary | + +A folder's position comes from `meta.json` only. The `order` in its `index.mdx` +frontmatter positions that page inside the group — it does not move the group. + +### One scale for pages and folders + +Pages and folders sort against each other using the same numbers, so a group can +sit between two loose pages: + +``` +index.mdx order: 1 +quick-start.mdx order: 2 +guides/meta.json order: 3 ← the group lands here +reference/meta.json order: 4 +``` + +### Ordering by filename + +If you would rather list the pages than number them, use `pages`: + +```json +{ + "title": "Guides", + "pages": ["introduction", "installation", "configuration"] +} +``` + +Anything not listed follows the listed pages. + +## Short labels for a narrow rail + +When a title is too long for the sidebar but the page is known by a code its +readers already use, add `short`: + +```yaml +title: Space Packet Protocol +short: SPP +``` + +The sidebar shows `SPP` and keeps the full title on the link's tooltip. Headings, +breadcrumbs, the browser tab and search all keep using `title`. + +This is read by the `fanfold` theme, whose rail is narrow enough to need it. The +`default` and `paper` themes always show `title`. + +## Icons + +`icon` puts a small graphic next to a sidebar entry: + +```yaml +icon: rectangle-stack +``` + +The available values are listed under +[`icon`](/docs/reference/frontmatter) in the frontmatter reference. + +## What else the navigation drives + +The same tree feeds the breadcrumbs at the top of a page, the previous and next +links at the bottom, and the order results come back in from search. Order the +tree well and all four improve at once. + +A page with `draft: true` is removed from the tree, so it disappears from all of +them. See [Pages and frontmatter](/docs/writing/pages). diff --git a/docs/content/docs/writing/pages.mdx b/docs/content/docs/writing/pages.mdx new file mode 100644 index 00000000..3c2cb1f7 --- /dev/null +++ b/docs/content/docs/writing/pages.mdx @@ -0,0 +1,119 @@ +--- +title: Pages and frontmatter +description: How a file becomes a page, and the frontmatter you will use every day. +order: 1 +--- + +A page is an `.mdx` file in a content directory. Create the file and it is on the +site — there is nothing to register. + +## Files become URLs + +The path under a content directory is the URL: + +``` +content/docs/index.mdx → /docs +content/docs/hello.mdx → /docs/hello +content/docs/guides/setup.mdx → /docs/guides/setup +content/docs/guides/index.mdx → /docs/guides +``` + +`index.mdx` is the page for the folder that holds it. `readme.mdx` does the same +job, which is useful when the folder is also browsed on GitHub. + +## Frontmatter + +Every page opens with a YAML block. `title` is the only one you always want: + +```mdx +--- +title: Installing the CLI +description: Get the command line tool onto your machine. +order: 2 +--- + +The CLI ships as a single binary. + +## Requirements +``` + +The four fields you will use on nearly every page: + +| Field | What it does | +|---|---| +| `title` | The heading above the article, the sidebar label, the browser tab. Always set it | +| `description` | The line under the heading, plus the meta description and social card | +| `order` | Where the page sits in the sidebar. Lower first | +| `draft` | `true` keeps the page out of the site while you write it | + +There are nine more fields for narrower jobs. See +[Frontmatter fields](/docs/reference/frontmatter) for the full list. + +## Do not repeat the title + +Every theme prints `title` above the article. A page that opens with its own +`# Installing the CLI` shows the same words twice. + +```mdx +--- +title: Installing the CLI +--- + +# Installing the CLI ← delete this + +The CLI ships as a single binary. +``` + +Start with your first sentence, and use `##` and below for sections. Those are +what the table of contents lists — a `#` in the body never appears there. + +## Writing MDX + +MDX is markdown plus components. Everything you expect from markdown works: +headings, lists, tables, links, code fences, block quotes, bold and italic. + +On top of that Chronicle gives you callouts, tabs, badges, collapsible sections +and Mermaid diagrams. See [Components](/docs/writing/components). + +Code fences are highlighted by Shiki. Name the language on the fence: + +````mdx +```bash +chronicle dev +``` +```` + +A fence with no language is rendered as plain text rather than failing, so an +ASCII diagram is safe to paste in. + +## Hiding a page while you write it + +Set `draft: true`: + +```mdx +--- +title: Not finished yet +draft: true +--- +``` + +The file stays where it is, but the page is dropped from the navigation tree — so +it is out of the sidebar, breadcrumbs, search, and the previous and next links. +This is how you keep unfinished work in the repository. + +## Reading time and last modified + +Reading time is measured from the page body and shown by the themes that have +somewhere to put it. You do not set it. + +`lastModified` is a date you set yourself when you want the page to state one: + +```yaml +lastModified: "2026-03-30" +``` + +## Next + +- [Navigation](/docs/writing/navigation) — ordering pages and naming folders +- [Components](/docs/writing/components) — callouts, tabs, diagrams +- [Frontmatter fields](/docs/reference/frontmatter) — all thirteen fields diff --git a/docs/public/logo-dark.svg b/docs/public/logo-dark.svg new file mode 100644 index 00000000..fcd8483f --- /dev/null +++ b/docs/public/logo-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/public/logo.svg b/docs/public/logo.svg new file mode 100644 index 00000000..623630b1 --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/basic/content/docs/api/endpoints.mdx b/examples/basic/content/docs/api/endpoints.mdx index 68b236cb..2b76578c 100644 --- a/examples/basic/content/docs/api/endpoints.mdx +++ b/examples/basic/content/docs/api/endpoints.mdx @@ -4,8 +4,6 @@ description: Available CLI commands order: 2 --- -# CLI Commands - ## chronicle init Initialize a new Chronicle project. diff --git a/examples/basic/content/docs/api/overview.mdx b/examples/basic/content/docs/api/overview.mdx index 2b36a29b..10be0a63 100644 --- a/examples/basic/content/docs/api/overview.mdx +++ b/examples/basic/content/docs/api/overview.mdx @@ -4,8 +4,6 @@ description: Overview of the Chronicle API order: 1 --- -# API Overview - Chronicle provides a simple API for building documentation. ## Core Concepts diff --git a/examples/basic/content/docs/components.mdx b/examples/basic/content/docs/components.mdx index 94d62759..eb777adc 100644 --- a/examples/basic/content/docs/components.mdx +++ b/examples/basic/content/docs/components.mdx @@ -5,8 +5,6 @@ order: 3 authors: [jane] --- -# Components - Live rendering of the MDX components Chronicle registers for content files. ## Badge diff --git a/examples/basic/content/docs/features.mdx b/examples/basic/content/docs/features.mdx index 6c31410b..c4761783 100644 --- a/examples/basic/content/docs/features.mdx +++ b/examples/basic/content/docs/features.mdx @@ -5,8 +5,6 @@ order: 2 draft: true --- -# Features - Chronicle is a self-hosted documentation platform built with Vite + Nitro. Here's what it offers. ## Content diff --git a/examples/basic/content/docs/getting-started.mdx b/examples/basic/content/docs/getting-started.mdx index 69f16e5a..716cb179 100644 --- a/examples/basic/content/docs/getting-started.mdx +++ b/examples/basic/content/docs/getting-started.mdx @@ -7,8 +7,6 @@ authors: - Sam Patel --- -# Getting Started - Get up and running with Chronicle in minutes. ## Prerequisites diff --git a/examples/basic/content/docs/guides/configuration.mdx b/examples/basic/content/docs/guides/configuration.mdx index 95f5ac18..24494649 100644 --- a/examples/basic/content/docs/guides/configuration.mdx +++ b/examples/basic/content/docs/guides/configuration.mdx @@ -4,8 +4,6 @@ description: Configure Chronicle options order: 2 --- -# Configuration - Chronicle uses a `chronicle.yaml` file for configuration. ## Basic Configuration diff --git a/examples/basic/content/docs/guides/deployment.mdx b/examples/basic/content/docs/guides/deployment.mdx index 89f54f1d..c6e9827d 100644 --- a/examples/basic/content/docs/guides/deployment.mdx +++ b/examples/basic/content/docs/guides/deployment.mdx @@ -4,8 +4,6 @@ description: Deploy Chronicle to production order: 3 --- -# Deployment - Chronicle builds to a standalone Node.js server that can be deployed anywhere. ## Build diff --git a/examples/basic/content/docs/guides/installation.mdx b/examples/basic/content/docs/guides/installation.mdx index f43dc85c..8447adc1 100644 --- a/examples/basic/content/docs/guides/installation.mdx +++ b/examples/basic/content/docs/guides/installation.mdx @@ -4,8 +4,6 @@ description: Detailed installation instructions order: 1 --- -# Installation - ## System Requirements | Requirement | Version | diff --git a/examples/basic/content/docs/index.mdx b/examples/basic/content/docs/index.mdx index 450435a2..1938c8af 100644 --- a/examples/basic/content/docs/index.mdx +++ b/examples/basic/content/docs/index.mdx @@ -4,7 +4,8 @@ description: Getting started with Chronicle documentation order: 1 --- -# Heading 1 +The page title above comes from frontmatter, so the headings below start at +`##`. A page body has no `#` of its own. ## Heading 2 diff --git a/examples/basic/content/docs/tips-&-tricks.mdx b/examples/basic/content/docs/tips-&-tricks.mdx index 9a89d35b..3bc5ec30 100644 --- a/examples/basic/content/docs/tips-&-tricks.mdx +++ b/examples/basic/content/docs/tips-&-tricks.mdx @@ -4,6 +4,4 @@ description: Useful tips and tricks order: 10 --- -# Tips & Tricks - Some helpful tips and tricks for using Chronicle. diff --git a/examples/versioned/content/dev/api.mdx b/examples/versioned/content/dev/api.mdx index ec45b23a..a00284e4 100644 --- a/examples/versioned/content/dev/api.mdx +++ b/examples/versioned/content/dev/api.mdx @@ -4,8 +4,6 @@ order: 2 authors: [jane, Sam Patel, Ana Ruiz ] --- -# Dev API notes — latest - Latest `/dev/api`. - [Dev Home](./index.mdx) diff --git a/examples/versioned/content/dev/index.mdx b/examples/versioned/content/dev/index.mdx index 1ff62d55..a9056ff6 100644 --- a/examples/versioned/content/dev/index.mdx +++ b/examples/versioned/content/dev/index.mdx @@ -3,8 +3,6 @@ title: Dev home (3.0) order: 1 --- -# Dev — latest - Latest `/dev`. - [API Notes](./api.mdx) diff --git a/examples/versioned/content/docs/guide.mdx b/examples/versioned/content/docs/guide.mdx index 30d3ba21..2dffea7b 100644 --- a/examples/versioned/content/docs/guide.mdx +++ b/examples/versioned/content/docs/guide.mdx @@ -4,8 +4,6 @@ order: 2 authors: [jane] --- -# Guide — latest docs - Latest `/docs/guide`. - [Docs Home](./index.mdx) diff --git a/examples/versioned/content/docs/index.mdx b/examples/versioned/content/docs/index.mdx index 289077b8..b24e5d4c 100644 --- a/examples/versioned/content/docs/index.mdx +++ b/examples/versioned/content/docs/index.mdx @@ -4,8 +4,6 @@ description: Latest docs landing order: 1 --- -# Docs — latest - This is `/docs` on latest (3.0). ## Links diff --git a/examples/versioned/versions/v1/dev/index.mdx b/examples/versioned/versions/v1/dev/index.mdx index e92a6fe5..0b6d3154 100644 --- a/examples/versioned/versions/v1/dev/index.mdx +++ b/examples/versioned/versions/v1/dev/index.mdx @@ -3,6 +3,4 @@ title: Developer Guide (1.0) order: 1 --- -# Developer Guide — v1 - In v1, `dev/` was relabeled "Developer Guide" and came first. diff --git a/examples/versioned/versions/v1/docs/index.mdx b/examples/versioned/versions/v1/docs/index.mdx index 1da05910..481cc0f4 100644 --- a/examples/versioned/versions/v1/docs/index.mdx +++ b/examples/versioned/versions/v1/docs/index.mdx @@ -3,6 +3,4 @@ title: Docs home (1.0) order: 2 --- -# Docs — v1 - `/v1/docs` landing. Legacy version. diff --git a/examples/versioned/versions/v2/docs/guide.mdx b/examples/versioned/versions/v2/docs/guide.mdx index 100acedd..90ca2011 100644 --- a/examples/versioned/versions/v2/docs/guide.mdx +++ b/examples/versioned/versions/v2/docs/guide.mdx @@ -3,6 +3,4 @@ title: Guide (v2) order: 2 --- -# Guide — v2 - `/v2/docs/guide`. diff --git a/examples/versioned/versions/v2/docs/index.mdx b/examples/versioned/versions/v2/docs/index.mdx index bb623bbb..bed465b7 100644 --- a/examples/versioned/versions/v2/docs/index.mdx +++ b/examples/versioned/versions/v2/docs/index.mdx @@ -3,6 +3,4 @@ title: Docs home (2.0) order: 1 --- -# Docs — v2 - `/v2/docs` landing. diff --git a/packages/chronicle/src/cli/commands/init.ts b/packages/chronicle/src/cli/commands/init.ts index 39fe84bb..dd1a4989 100644 --- a/packages/chronicle/src/cli/commands/init.ts +++ b/packages/chronicle/src/cli/commands/init.ts @@ -15,14 +15,13 @@ export const defaultInitConfig: ChronicleConfig = { search: { enabled: true, placeholder: 'Search documentation...' } }; +// No `# Welcome`: themes print the frontmatter title above the article. const sampleMdx = `--- title: Welcome description: Getting started with your documentation order: 1 --- -# Welcome - This is your documentation home page. `; diff --git a/packages/chronicle/src/cli/commands/static-generate.ts b/packages/chronicle/src/cli/commands/static-generate.ts index 10760b2b..b61be5f4 100644 --- a/packages/chronicle/src/cli/commands/static-generate.ts +++ b/packages/chronicle/src/cli/commands/static-generate.ts @@ -22,7 +22,7 @@ import { buildLlmsTxt, type LlmsPage } from '@/lib/llms'; import { DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg, splitVersion } from '@/lib/image-utils'; import { isAnimatedImage } from '@/lib/image-animation'; import { getAssetVersion } from '@/lib/asset-version'; -import type { VersionContext } from '@/lib/version-source'; +import { contentSectionPrefixes, sectionOf, type VersionContext } from '@/lib/version-source'; import type { Frontmatter, PageNavLink } from '@/types'; import { buildAuthorIndex } from '@/lib/author-index'; import { normalizeAuthorList, resolveAuthors } from '@/lib/authors'; @@ -367,19 +367,33 @@ function flattenTreeUrls(tree: PageTreeRoot): { url: string; title: string }[] { return result; } -function computeNavigation(tree: PageTreeRoot): Map { +function computeNavigation( + tree: PageTreeRoot, + config: ChronicleConfig, +): Map { const navMap = new Map(); - const ordered = flattenTreeUrls(tree); - - for (let i = 0; i < ordered.length; i++) { - navMap.set(ordered[i].url, { - prev: i > 0 - ? { url: ordered[i - 1].url, title: ordered[i - 1].title } - : null, - next: i < ordered.length - 1 - ? { url: ordered[i + 1].url, title: ordered[i + 1].title } - : null, - }); + + // Chained per section, matching `getNavMap` in source.ts. + const prefixes = contentSectionPrefixes(config); + const bySection = new Map>(); + for (const entry of flattenTreeUrls(tree)) { + const key = sectionOf(entry.url, prefixes) ?? ''; + const group = bySection.get(key); + if (group) group.push(entry); + else bySection.set(key, [entry]); + } + + for (const ordered of bySection.values()) { + for (let i = 0; i < ordered.length; i++) { + navMap.set(ordered[i].url, { + prev: i > 0 + ? { url: ordered[i - 1].url, title: ordered[i - 1].title } + : null, + next: i < ordered.length - 1 + ? { url: ordered[i + 1].url, title: ordered[i + 1].title } + : null, + }); + } } return navMap; @@ -1064,7 +1078,7 @@ export async function generateStaticSite(options: StaticGenerateOptions): Promis const contentMirror = path.resolve(packageRoot, '.content'); const folderMeta = await scanFolderMeta(contentMirror, config); const tree = buildPageTree(pages, config, folderMeta); - const navMap = computeNavigation(tree); + const navMap = computeNavigation(tree, config); // Generate all static assets console.log(chalk.gray(' Generating page data files...')); diff --git a/packages/chronicle/src/components/ui/logo.module.css b/packages/chronicle/src/components/ui/logo.module.css new file mode 100644 index 00000000..166407a8 --- /dev/null +++ b/packages/chronicle/src/components/ui/logo.module.css @@ -0,0 +1,17 @@ +/* Explicit tokens rather than `currentColor`: Apsara colours its text + components, not their containers, so the inherited colour is black in both + themes and a mark drawn from it disappears on a dark ground. */ +.initial { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: var(--rs-radius-3); + background: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); + font-weight: 600; + line-height: 1; + letter-spacing: 0; + text-transform: none; + user-select: none; +} diff --git a/packages/chronicle/src/components/ui/logo.tsx b/packages/chronicle/src/components/ui/logo.tsx new file mode 100644 index 00000000..db4f2831 --- /dev/null +++ b/packages/chronicle/src/components/ui/logo.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useTheme } from '@raystack/apsara'; +import { cx } from 'class-variance-authority'; +import { siteInitial } from '@/lib/site-initial'; +import type { ChronicleConfig } from '@/types'; +import styles from './logo.module.css'; + +interface SiteInitialProps { + title: string; + size?: number; + className?: string; + /** Omit to hide it from assistive technology. */ + label?: string; +} + +/** A site's first letter in a tinted box. The mark for a site that sets none. */ +export function SiteInitial({ + title, + size = 28, + className, + label +}: SiteInitialProps) { + return ( + + {siteInitial(title)} + + ); +} + +interface LogoProps { + config: ChronicleConfig; + /** Box the logo is drawn in, in px. Square. */ + size?: number; + className?: string; + /** + * Whether assistive technology should announce it. Pass `false` where the + * site name is already rendered beside it, or it is read out twice. + */ + labelled?: boolean; +} + +/** + * A site's logo: whichever of `logo.light` / `logo.dark` suits the active + * theme, falling back to the site's initial when it sets neither. + */ +export function Logo({ + config, + size = 28, + className, + labelled = true +}: LogoProps) { + const { resolvedTheme } = useTheme(); + const logo = config.logo; + + const src = + resolvedTheme === 'dark' + ? (logo?.dark ?? logo?.light) + : (logo?.light ?? logo?.dark); + + if (src) { + return ( + {labelled + ); + } + + return ( + + ); +} diff --git a/packages/chronicle/src/lib/remark-resolve-images.test.ts b/packages/chronicle/src/lib/remark-resolve-images.test.ts index c4d9d1a7..18e2b1b2 100644 --- a/packages/chronicle/src/lib/remark-resolve-images.test.ts +++ b/packages/chronicle/src/lib/remark-resolve-images.test.ts @@ -141,3 +141,62 @@ describe('remark-resolve-images version stamping', () => { expect(firstImageUrl(tree)).toBe(`/_content/docs/img.png?v=${PNG_HASH}`); }); }); + +describe('versioned pages', () => { + function setupVersionsDir(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chronicle-remark-v-')); + const versionsDir = path.join(root, 'versions'); + fs.mkdirSync(path.join(versionsDir, 'v1', 'docs'), { recursive: true }); + fs.writeFileSync(path.join(versionsDir, 'v1', 'docs', 'img.png'), PNG_BYTES); + return versionsDir; + } + + test('exports images for a page under versions/', async () => { + // `valueToExport` names `images`, so a page that never sets it fails the + // production build with a missing-export error. Every versioned page used + // to take that path, which meant no versioned site could be built. + const versionsDir = setupVersionsDir(); + const { file } = await transform( + 'no images here', + undefined, + versionsDir, + path.join(versionsDir, 'v1', 'docs', 'page.mdx') + ); + expect(file.data.images).toEqual([]); + }); + + test('resolves a relative image under versions/ against the mirror path', async () => { + const versionsDir = setupVersionsDir(); + const { tree } = await transform( + '![alt](./img.png)', + { optimize: false }, + versionsDir, + path.join(versionsDir, 'v1', 'docs', 'page.mdx') + ); + expect(firstImageUrl(tree)).toBe(`/_content/v1/docs/img.png?v=${PNG_HASH}`); + }); + + test('takes the deepest marker when a project sits under a versions dir', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chronicle-remark-d-')); + const nested = path.join(root, 'versions', 'project', 'content'); + fs.mkdirSync(path.join(nested, 'docs'), { recursive: true }); + fs.writeFileSync(path.join(nested, 'docs', 'img.png'), PNG_BYTES); + const { tree } = await transform( + '![alt](./img.png)', + { optimize: false }, + nested, + path.join(nested, 'docs', 'page.mdx') + ); + expect(firstImageUrl(tree)).toBe(`/_content/docs/img.png?v=${PNG_HASH}`); + }); + + test('exports an empty list when the path is outside any content root', async () => { + const { file } = await transform( + 'stray file', + undefined, + '/tmp', + '/tmp/elsewhere/page.mdx' + ); + expect(file.data.images).toEqual([]); + }); +}); diff --git a/packages/chronicle/src/lib/remark-resolve-images.ts b/packages/chronicle/src/lib/remark-resolve-images.ts index 836de3e7..f4469db4 100644 --- a/packages/chronicle/src/lib/remark-resolve-images.ts +++ b/packages/chronicle/src/lib/remark-resolve-images.ts @@ -57,18 +57,40 @@ function finalizeUrl(url: string, optimize: boolean, version?: string): string { const IMG_SRC_PATTERN = /(]*\bsrc=["'])([^"']+)(["'])/gi +/** + * Splits a page's path at its content root. The part below the split is also + * its path under `/_content/`, which is what image URLs are built from — + * `content/docs/` mirrors as `docs/`, `versions/v1/docs/` as `v1/docs/`. + * + * Takes the deepest marker, since a project can sit inside a directory named + * `versions` and still keep its pages under `content/`. + */ +function splitContentRoot( + filePath: string, +): { root: string; relative: string } | null { + let end = -1 + for (const marker of ['/versions/', '/content/']) { + const idx = filePath.lastIndexOf(marker) + if (idx !== -1) end = Math.max(end, idx + marker.length) + } + if (end === -1) return null + return { root: filePath.slice(0, end), relative: filePath.slice(end) } +} + const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => { const optimize = options?.optimize ?? true return async (tree, file) => { + // `valueToExport` names `images`, so returning without it fails the build. + file.data.images = [] + const filePath = file.path?.replace(/\\/g, '/') if (!filePath) return - const contentIdx = filePath.lastIndexOf('/content/') - if (contentIdx === -1) return + const split = splitContentRoot(filePath) + if (!split) return - const relative = filePath.slice(contentIdx + '/content/'.length) + const { root: contentRoot, relative } = split const dir = path.posix.dirname(relative) - const contentRoot = filePath.slice(0, contentIdx + '/content/'.length) const seen = new Set() const images: string[] = [] diff --git a/packages/chronicle/src/lib/site-initial.test.ts b/packages/chronicle/src/lib/site-initial.test.ts new file mode 100644 index 00000000..fbc833e9 --- /dev/null +++ b/packages/chronicle/src/lib/site-initial.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { siteInitial } from './site-initial' + +describe('siteInitial', () => { + test('takes the first letter, uppercased', () => { + expect(siteInitial('Chronicle')).toBe('C') + expect(siteInitial('my documentation')).toBe('M') + }) + + test('ignores surrounding space', () => { + expect(siteInitial(' Chronicle ')).toBe('C') + }) + + test('keeps a whole codepoint', () => { + // Splitting on UTF-16 units would return half a surrogate pair. + expect(siteInitial('🚀 Launch')).toBe('🚀') + expect(siteInitial('日本語ドキュメント')).toBe('日') + }) + + test('falls back when there is nothing to take', () => { + expect(siteInitial('')).toBe('?') + expect(siteInitial(' ')).toBe('?') + }) +}) diff --git a/packages/chronicle/src/lib/site-initial.ts b/packages/chronicle/src/lib/site-initial.ts new file mode 100644 index 00000000..eb281303 --- /dev/null +++ b/packages/chronicle/src/lib/site-initial.ts @@ -0,0 +1,8 @@ +/** + * The letter a site is reduced to when it sets no `logo`. Codepoint-safe, so an + * emoji or a non-Latin script survives being taken apart. + */ +export function siteInitial(title: string): string { + const first = Array.from(title.trim())[0] + return first ? first.toUpperCase() : '?' +} diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index c377bd9a..6d9d6646 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -19,9 +19,11 @@ import { loadConfig, } from './config'; import { + contentSectionPrefixes, filterPagesByVersion, filterPageTreeByVersion, resolveVersionFromUrl, + sectionOf, type VersionContext, } from './version-source'; import type { Frontmatter, PageNav, PageNavLink } from '@/types'; @@ -303,12 +305,25 @@ async function getNavMap(): Promise> { ? p.name : titleFromUrl(p.url) }); + + // Chained per section: the last page of one should not lead into another. + const prefixes = contentSectionPrefixes(loadConfig()); + const bySection = new Map(); + for (const page of pages) { + const key = sectionOf(page.url, prefixes) ?? ''; + const group = bySection.get(key); + if (group) group.push(page); + else bySection.set(key, [page]); + } + const navMap = new Map(); - for (let i = 0; i < pages.length; i++) { - navMap.set(pages[i].url, { - prev: i > 0 ? toLink(pages[i - 1]) : null, - next: i < pages.length - 1 ? toLink(pages[i + 1]) : null - }); + for (const group of bySection.values()) { + for (let i = 0; i < group.length; i++) { + navMap.set(group[i].url, { + prev: i > 0 ? toLink(group[i - 1]) : null, + next: i < group.length - 1 ? toLink(group[i + 1]) : null + }); + } } cachedNavMap = navMap; return cachedNavMap; diff --git a/packages/chronicle/src/lib/theme-colors.test.ts b/packages/chronicle/src/lib/theme-colors.test.ts new file mode 100644 index 00000000..5a8d1e88 --- /dev/null +++ b/packages/chronicle/src/lib/theme-colors.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { buildThemeColorCss } from './theme-colors' + +describe('buildThemeColorCss', () => { + test('returns nothing when no colours are set', () => { + expect(buildThemeColorCss(undefined)).toBe('') + expect(buildThemeColorCss({})).toBe('') + }) + + test('prefixes a bare token name', () => { + const css = buildThemeColorCss({ 'background-base-primary': '#ffffff' }) + expect(css).toContain('--rs-color-background-base-primary: #ffffff;') + }) + + test('leaves a full custom property alone', () => { + const css = buildThemeColorCss({ '--paper-ink': '#101010' }) + expect(css).toContain('--paper-ink: #101010;') + expect(css).not.toContain('--rs-color---paper-ink') + }) + + test('wins in both themes', () => { + // Apsara defines its dark values behind `[data-theme]`, which outranks a + // bare `:root` — so an override listed only there would never apply. + const css = buildThemeColorCss({ 'foreground-base-primary': 'red' }) + expect(css).toContain(":root[data-theme='dark']") + expect(css).toContain(":root[data-theme='light']") + }) + + test('accepts colour functions and variable references', () => { + const css = buildThemeColorCss({ + a: 'rgb(10, 20, 30)', + b: 'color-mix(in srgb, #fff 50%, #000)', + c: 'var(--rs-color-background-base-primary)', + }) + expect(css).toContain('rgb(10, 20, 30)') + expect(css).toContain('color-mix(in srgb, #fff 50%, #000)') + expect(css).toContain('var(--rs-color-background-base-primary)') + }) + + test('drops anything that could break out of the declaration', () => { + const css = buildThemeColorCss({ + good: '#fff', + 'bad-value': 'red; } body { display: none', + 'bad;key': '#000', + 'url-value': 'url(https://evil.example/x.png)', + 'at-rule': '@import "evil.css"', + }) + expect(css).toContain('--rs-color-good: #fff;') + expect(css).not.toContain('display: none') + expect(css).not.toContain('bad;key') + expect(css).not.toContain('evil.example') + expect(css).not.toContain('@import') + }) + + test('drops empty and non-string values', () => { + const css = buildThemeColorCss({ + good: '#fff', + empty: '', + nope: undefined as unknown as string, + }) + expect(css).toContain('--rs-color-good') + expect(css).not.toContain('--rs-color-empty') + expect(css).not.toContain('--rs-color-nope') + }) +}) diff --git a/packages/chronicle/src/lib/theme-colors.ts b/packages/chronicle/src/lib/theme-colors.ts new file mode 100644 index 00000000..c1f35008 --- /dev/null +++ b/packages/chronicle/src/lib/theme-colors.ts @@ -0,0 +1,38 @@ +/** + * Turns `theme.colors` from `chronicle.yaml` into a stylesheet. A bare key is + * an Apsara token and takes the `--rs-color-` prefix; a full custom property is + * used as written. + */ + +/** A token name or a full custom property. Nothing else reaches the CSS. */ +const KEY_PATTERN = /^(--)?[a-z][a-z0-9-]*$/ + +/** Colours only. This is written into a stylesheet, so keep it narrow. */ +const VALUE_PATTERN = /^[a-zA-Z0-9#(),.%\s/_-]+$/ + +function toCustomProperty(key: string): string { + return key.startsWith('--') ? key : `--rs-color-${key}` +} + +/** Declarations for a config's colours. Malformed entries are dropped. */ +export function buildThemeColorCss( + colors: Record | undefined, +): string { + if (!colors) return '' + + const declarations = Object.entries(colors) + .filter( + ([key, value]) => + KEY_PATTERN.test(key) && + typeof value === 'string' && + value.length > 0 && + VALUE_PATTERN.test(value), + ) + .map(([key, value]) => `${toCustomProperty(key)}: ${value.trim()};`) + + if (declarations.length === 0) return '' + + // Apsara's dark values sit behind `[data-theme]`, which outranks a bare + // `:root` — so both selectors are needed for an override to hold. + return `:root,\n:root[data-theme='light'],\n:root[data-theme='dark'] {\n ${declarations.join('\n ')}\n}` +} diff --git a/packages/chronicle/src/lib/tree-utils.test.ts b/packages/chronicle/src/lib/tree-utils.test.ts index 0c4aaca8..be508a57 100644 --- a/packages/chronicle/src/lib/tree-utils.test.ts +++ b/packages/chronicle/src/lib/tree-utils.test.ts @@ -181,7 +181,7 @@ describe('compactTree', () => { expect(result.children[0]).toEqual({ type: 'page', name: 'Intro', url: '/docs/intro' }) }) - test('strips description and root from folder nodes', () => { + test('strips description from folder nodes but keeps root', () => { const tree: Root = { name: 'root', children: [{ @@ -193,7 +193,8 @@ describe('compactTree', () => { const result = compactTree(tree) const folder = result.children[0] as any expect(folder.description).toBeUndefined() - expect(folder.root).toBeUndefined() + // Layouts scope the tree by `root`, so it has to survive serialisation. + expect(folder.root).toBe(true) expect(folder.name).toBe('Guides') expect(folder.children[0]).toEqual({ type: 'page', name: 'Install', url: '/guides/install' }) }) diff --git a/packages/chronicle/src/lib/tree-utils.ts b/packages/chronicle/src/lib/tree-utils.ts index 70eab8a1..c2b07356 100644 --- a/packages/chronicle/src/lib/tree-utils.ts +++ b/packages/chronicle/src/lib/tree-utils.ts @@ -14,6 +14,10 @@ const KEEP_FIELDS = new Set([ 'index', ]); +// `root` marks a content directory, which layouts scope the tree by. Folders +// only — it means nothing on a page or a separator. +const FOLDER_FIELDS = new Set([...KEEP_FIELDS, 'root']); + function compactLeaf(node: Node): Node { const out: Record = {}; for (const [k, v] of Object.entries(node)) { @@ -27,7 +31,7 @@ function compactNode(node: Node): Node { if (node.type !== 'folder') return compactLeaf(node); const out: Record = {}; for (const [k, v] of Object.entries(node)) { - if (!KEEP_FIELDS.has(k)) continue; + if (!FOLDER_FIELDS.has(k)) continue; if (k === 'children') out.children = (v as Node[]).map(compactNode); else if (k === 'index') out.index = compactLeaf(v as Node); else out[k] = v; diff --git a/packages/chronicle/src/lib/version-source.test.ts b/packages/chronicle/src/lib/version-source.test.ts index 2953c104..67d5448c 100644 --- a/packages/chronicle/src/lib/version-source.test.ts +++ b/packages/chronicle/src/lib/version-source.test.ts @@ -40,6 +40,15 @@ function folder(name: string, children: (Item | Folder)[]): Folder { return { type: 'folder', name, children } } +/** A content directory. `buildFiles` marks every one of these with `root`. */ +function contentRoot( + name: string, + children: (Item | Folder)[], + index?: Item, +): Folder { + return { type: 'folder', name, root: true, children, ...(index ? { index } : {}) } +} + describe('resolveVersionFromUrl', () => { const config = makeConfig() @@ -126,11 +135,24 @@ describe('filterPageTreeByVersion', () => { ) expect(filtered.children).toEqual([]) }) + + test('leaves an already scoped tree alone', () => { + // `entry-server` scopes the tree before serialising it, and then a layout + // scopes what it is handed. The second pass used to mistake v1's first + // content folder for the version folder and drop the rest. + const scoped: Root = { name: 'root', children: v1Folder.children } + const filtered = filterPageTreeByVersion( + scoped, + { dir: 'v1', urlPrefix: '/v1' }, + config, + ) + expect(filtered.children).toEqual(v1Folder.children) + }) }) describe('filterPageTreeByContentDir', () => { - const latestDocs = folder('docs', [page('/docs/a'), page('/docs/b')]) - const latestDev = folder('dev', [page('/dev/x')]) + const latestDocs = contentRoot('docs', [page('/docs/a'), page('/docs/b')]) + const latestDev = contentRoot('dev', [page('/dev/x')]) const latestTree: Root = { name: 'root', children: [latestDocs, latestDev], @@ -152,12 +174,65 @@ describe('filterPageTreeByContentDir', () => { }) test('uses version urlPrefix to disambiguate within a version', () => { - const v1Docs = folder('docs', [page('/v1/docs/a')]) - const v1Dev = folder('dev', [page('/v1/dev/x')]) + const v1Docs = contentRoot('docs', [page('/v1/docs/a')]) + const v1Dev = contentRoot('dev', [page('/v1/dev/x')]) const ctx = { dir: 'v1', urlPrefix: '/v1' } const tree: Root = { name: 'root', children: [v1Docs, v1Dev] } expect( filterPageTreeByContentDir(tree, ctx, 'dev').children, ).toEqual(v1Dev.children) }) + + test('unwraps the content-dir folder on a single-content-dir site', () => { + // The whole tree is under /docs here, so an "every url matches the prefix" + // test would call it already scoped and leave the wrapper in place — its + // label then shows as a heading above every page in the sidebar. + const wrapped: Root = { + name: 'root', + children: [contentRoot('Docs', [page('/docs/a'), page('/docs/b')], page('/docs'))], + } + const out = filterPageTreeByContentDir(wrapped, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual([page('/docs/a'), page('/docs/b')]) + }) + + test('does not mistake a sub-folder for the content dir', () => { + // Already scoped, and its only child is a folder. `guides` sits at + // /docs/guides, not /docs, so it is not the wrapper. + const scoped: Root = { + name: 'root', + children: [folder('guides', [page('/docs/guides/a'), page('/docs/guides/b')])], + } + const out = filterPageTreeByContentDir(scoped, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual(scoped.children) + }) + + test('unwraps a section that holds only sub-folders', () => { + // No index page and no page directly under the prefix. Identifying the + // wrapper by url shape alone got this wrong and emptied the sidebar. + const docs = contentRoot('Docs', [folder('guides', [page('/docs/guides/a')])]) + const dev = contentRoot('Dev', [page('/dev/x')]) + const tree: Root = { name: 'root', children: [docs, dev] } + const out = filterPageTreeByContentDir(tree, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual(docs.children) + }) + + test('leaves an already scoped flat tree alone', () => { + // A single content directory with no sub-folders: after `entry-server` + // scopes it the children are the pages themselves, and looking for a + // wrapping folder again found none and emptied the navigation. + const scoped: Root = { name: 'root', children: latestDocs.children } + const out = filterPageTreeByContentDir(scoped, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual(latestDocs.children) + }) + + test('leaves an already scoped tree with sub-folders alone', () => { + // Same tree, but with sub-folders: the second pass used to match the first + // sub-folder — every url in it is under `/docs` — and show only its pages. + const scoped: Root = { + name: 'root', + children: [page('/docs'), folder('guides', [page('/docs/guides/a')])], + } + const out = filterPageTreeByContentDir(scoped, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual(scoped.children) + }) }) diff --git a/packages/chronicle/src/lib/version-source.ts b/packages/chronicle/src/lib/version-source.ts index 1de0685b..626c5ac9 100644 --- a/packages/chronicle/src/lib/version-source.ts +++ b/packages/chronicle/src/lib/version-source.ts @@ -1,5 +1,6 @@ import type { Folder, Node, Root } from 'fumadocs-core/page-tree' import type { ChronicleConfig } from '@/types' +import { getLatestContentRoots, getVersionContentRoots } from './config' export interface VersionContext { dir: string | null @@ -66,12 +67,23 @@ function nodeMatchesVersion( return urls.every((u) => !prefixes.some((pre) => isUnderPrefix(u, pre))) } +/** + * True when `tree` has already been narrowed to `prefix`. Both filters run + * twice on the same tree — `entry-server` before serialising, then a layout — + * so both have to be safe to repeat. + */ +function isAlreadyScoped(tree: Root, prefix: string): boolean { + const urls = tree.children.flatMap(nodeUrls) + return urls.length > 0 && urls.every((u) => isUnderPrefix(u, prefix)) +} + export function filterPageTreeByVersion( tree: Root, ctx: VersionContext, config: ChronicleConfig, ): Root { if (ctx.dir !== null) { + if (isAlreadyScoped(tree, ctx.urlPrefix)) return tree const versionFolder = tree.children.find( (n): n is Folder => n.type === 'folder' && nodeMatchesVersion(n, ctx, config), @@ -91,11 +103,36 @@ export function filterPageTreeByContentDir( ): Root { if (contentDir === null) return tree const expectedPrefix = `${ctx.urlPrefix}/${contentDir}` + + // `root` marks a content directory — `buildFiles` sets it on every one. A + // sub-folder never carries it, which is what url shapes alone cannot tell us. const match = tree.children.find((n): n is Folder => { - if (n.type !== 'folder') return false + if (n.type !== 'folder' || n.root !== true) return false const urls = nodeUrls(n) return urls.length > 0 && urls.every((u) => isUnderPrefix(u, expectedPrefix)) }) - if (!match) return { ...tree, children: [] } - return { ...tree, children: match.children } + if (match) return { ...tree, children: match.children } + + if (isAlreadyScoped(tree, expectedPrefix)) return tree + return { ...tree, children: [] } +} + + +/** + * Every content section's URL prefix — `/docs`, `/dev`, `/v1/docs` — longest + * first, so `/v1/docs` is tested before `/v1` could ever shadow it. + */ +export function contentSectionPrefixes(config: ChronicleConfig): string[] { + const prefixes = [ + ...getLatestContentRoots(config).map((r) => r.urlPrefix), + ...(config.versions ?? []).flatMap((v) => + getVersionContentRoots(config, v.dir).map((r) => r.urlPrefix), + ), + ] + return prefixes.sort((a, b) => b.length - a.length) +} + +/** The section a page belongs to, or null if it sits outside every one. */ +export function sectionOf(url: string, prefixes: string[]): string | null { + return prefixes.find((p) => isUnderPrefix(url, p)) ?? null } diff --git a/packages/chronicle/src/pages/NotFound.tsx b/packages/chronicle/src/pages/NotFound.tsx index 7ae60c2a..bfd33501 100644 --- a/packages/chronicle/src/pages/NotFound.tsx +++ b/packages/chronicle/src/pages/NotFound.tsx @@ -1,10 +1,16 @@ import { FileTextIcon } from '@/components/ui/icons'; import { EmptyState } from '@raystack/apsara'; +import { Head } from '@/lib/head'; +import { usePageContext } from '@/lib/page-context'; import styles from './NotFound.module.css'; export function NotFound() { + const { config } = usePageContext(); + return (
+ {/* The requested page renders no ``, so name the tab here. */} + } heading="404" diff --git a/packages/chronicle/src/pages/RenderError.tsx b/packages/chronicle/src/pages/RenderError.tsx index e797e18b..bd9fa813 100644 --- a/packages/chronicle/src/pages/RenderError.tsx +++ b/packages/chronicle/src/pages/RenderError.tsx @@ -1,10 +1,15 @@ import { WarningIcon } from '@/components/ui/icons'; import { EmptyState } from '@raystack/apsara'; +import { Head } from '@/lib/head'; +import { usePageContext } from '@/lib/page-context'; import styles from './NotFound.module.css'; export function RenderError({ message }: { message: string | null }) { + const { config } = usePageContext(); + return (
+ } heading="Failed to render page" diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx index 667dd892..343118c5 100644 --- a/packages/chronicle/src/server/App.tsx +++ b/packages/chronicle/src/server/App.tsx @@ -80,6 +80,10 @@ function PageFallback() { ); } +/** + * Site-wide head tags. No `` — every route renders its own, and one here + * would hoist ahead of it, which is the title the browser would then use. + */ function RootHead({ config }: { config: ChronicleConfig }) { const siteJsonLd = config.url ? { @@ -91,15 +95,12 @@ function RootHead({ config }: { config: ChronicleConfig }) { } : null; + if (!siteJsonLd) return null; + return ( - <> - <title>{config.site.title} - {siteJsonLd && ( -