Skip to content

feat: multi-content + versioned docs#40

Open
rsbh wants to merge 43 commits intomainfrom
feat_multi_content_support
Open

feat: multi-content + versioned docs#40
rsbh wants to merge 43 commits intomainfrom
feat_multi_content_support

Conversation

@rsbh
Copy link
Copy Markdown
Member

@rsbh rsbh commented Apr 21, 2026

Summary

Adds versioned, multi-content support to Chronicle. A project can now declare multiple top-level content directories (e.g. docs/, dev/) and an ordered list of older versions (v1/, v2/), each with its own content dirs, labels, badge, and API specs. URLs are prefixed for older versions and unprefixed for latest. Search, llms.txt, sidebar, and a new version switcher are all version-aware.

Config shape

site:
  title: My Docs
  description: My Docs description

content:
  - dir: docs
    label: Docs
  - dir: dev
    label: Dev Docs

latest:
  label: "3.0"
  landing: true

versions:
  - dir: v1
    label: "1.0"
    landing: true
    badge: { label: deprecated, variant: warning }
    content:
      - dir: dev
        label: Developer Guide
      - dir: docs
        label: Docs
    api:
      - { name: REST API (v1), spec: ./v1-openapi.yaml, basePath: /apis, server: { url: https://api.example.com/v1 } }

Filesystem:

content/
├── docs/
└── dev/
versions/
└── v1/
    ├── docs/
    └── dev/

What shipped (by phase)

  • Schema + loader — new {site, content[], latest, versions[]} shape, zod strict root, uniqueness refines, lodash/uniqBy for dedup checks. site.description moved from top-level.
  • Content mirrorbuildContentMirror symlinks each (version, contentDir) into packageRoot/.content/; the vite dev/build plugin rebuilds it per run. Legacy single-symlink mirrors are replaced automatically.
  • Version-aware sourcefilterPageTreeByVersion, filterPageTreeByContentDir, filterPagesByVersion, resolveVersionFromUrl. Synthetic meta.json entries injected per content root (runtime, no filesystem writes) so sidebars render each content dir as a flat root.
  • RoutingRouteType enum + pure resolveRoute(url, config) classifier; SSR emits 302 for single-content roots, 404 for missing pages, and hands the rest off to the client. latest.landing / versions[].landing opt into a chromeless landing page with content-dir cards.
  • APIs/apis vs /<v>/apis; per-version spec loading via getApiConfigsForVersion.
  • Search + metadata/api/search?tag=<version>, per-version MiniSearch index cached by version key; canonical URLs emitted in <head> when config.url is set.
  • llms.txt/llms.txt for latest, /<v>/llms.txt for each version via a catch-all [version]/llms.txt.ts route.
  • Sidebar + switcher — Apsara DropdownMenu-based version switcher; default theme has 3 content-dir buttons + More overflow dropdown, paper theme uses stacked dropdowns in the sidebar.
  • chronicle init — scaffolds content/<dir>/, the new chronicle.yaml, and a sample index.mdx. runInit() is unit-tested.
  • Docs + examplesexamples/basic moved into content/docs/, new examples/versioned fixture with versions/v1/ + versions/v2/, docs/content/docs/configuration.mdx rewritten for the new schema. npm scripts added for each example.
  • CI.github/workflows/ci.yml runs bun run lint + bun test on PRs and main pushes.

Breaking changes

  • Top-level titlesite.title.
  • Top-level descriptionsite.description.
  • content: <string> removed; content: is now {dir, label}[].
  • Content lives under content/<dir>/ (latest) and versions/<v>/<dir>/ (old).
  • --content CLI flag removed; content location is fully config-driven.
  • Landing page is opt-in via landing: true (default false = 302 to first content dir).

Test plan

  • bun test — 79 tests pass across 7 files
  • bun run lint — 0 errors
  • Dev server boot with docs/chronicle.yaml (single-content)
  • Dev server boot with examples/versioned/chronicle.yaml (multi-content + versioned + badges)
  • Verified /, /v1, /v2 routes honour landing flag (302 vs landing render)
  • Verified per-content sidebar scoping and version-switcher navigation
  • Manual sanity check with examples/basic (API specs rendering)

🤖 Generated with Claude Code

rsbh and others added 25 commits April 21, 2026 10:05
Rewrite chronicleConfigSchema for multi-content + versioning:
- site.title replaces top-level title
- content is now {dir,label}[] (single string form removed)
- latest + versions[] with per-version content, api, and badge
- badge variant maps to Apsara Badge color prop
- strict root + dir-uniqueness refines

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Validate via chronicleConfigSchema.parse instead of ad-hoc spread
- Default config uses new {site, content[]} shape
- Add helpers: getLatestContentRoots, getVersionContentRoots, getAllVersions
- ContentRoot resolves fs path (content/<dir> or versions/<v>/<dir>) and URL prefix

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Validates parse rules: required site, non-empty content[], strict root
- Rejects legacy title, content:string, versions-without-latest, duplicate dirs
- Covers getLatestContentRoots, getVersionContentRoots, getAllVersions order
- Covers loadConfig fallback + yaml parsing via __CHRONICLE_CONFIG_RAW__

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- loadCLIConfig returns projectRoot (configPath's dirname) and drops
  resolveContentDir now that content is an array of {dir,label}
- Commands drop --content flag; content path is config-driven
- Vite define __CHRONICLE_CONTENT_DIR__ points at packageRoot/.content
  mirror so downstream routes remain stable as the mirror grows to
  include versioned subtrees
- linkContent still called with a bridge path (projectRoot/content);
  scaffold gets its real multi-root rewrite next commit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
linkContent now takes (projectRoot, config) and rebuilds
packageRoot/.content to mirror the configured layout:

  .content/<contentDir>           → <projectRoot>/content/<contentDir>
  .content/<versionDir>/<contentDir> → <projectRoot>/versions/<v>/<contentDir>

The mirror is wiped on each run (handles legacy single-symlink and
stale entries), then rebuilt from getLatestContentRoots and
getVersionContentRoots. CLI commands pass config through and rename
vite's return to viteConfig to avoid shadowing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure tree/page filters live in src/lib/version-source.ts, keyed off
the config's versions[]. source.ts exposes thin wrappers:

- getPageTreeForVersion(ctx) returns a subtree scoped to the version
- getPagesForVersion(ctx)   returns pages filtered to the version
- getVersionContextForUrl   resolves URL -> VersionContext

Latest (ctx.dir === null) excludes anything under /<versionDir>/*,
while a version returns only its subtree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract buildContentMirror(mirrorRoot, projectRoot, config) as the pure
unit; linkContent stays a thin wrapper binding PACKAGE_ROOT/.content.

Tests use tmpdir to exercise:
- single and multi content latest layouts
- nested versioned layout
- idempotency on re-run
- stale entries wiped when config shrinks
- legacy single-symlink mirror replaced by directory + child symlinks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- resolveVersionFromUrl: matches versions exactly, falls back to latest
  (no false positive on substring overlap e.g. /v1 vs /v1beta)
- filterPagesByVersion: latest excludes versioned pages, version scopes
  to its prefix
- filterPageTreeByVersion: latest strips version folders, version unwraps
  its folder, absent version returns empty

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
resolveRoute(pathname, config) classifies a URL into:

- redirect      single-content root -> /<dir> or /<v>/<dir>
- docs-index    multi-content root (latest or versioned) for landing
- docs-page     slug is the full URL slug incl. version prefix
                (fumadocs page URLs already include /<v>/ so the
                mirror + loader handle lookup without stripping)
- api-index / api-page  /apis or /<v>/apis routes

Resolver stays classifier-only; invalid content dirs fall through
to docs-page and let page lookup return 404 downstream.

Tests cover single/multi/versioned configs, trailing slash, and
version-shaped-but-unknown prefixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace string literal types with a `RouteType` const-object so
callers can reference RouteType.DocsPage etc. instead of repeating
hyphenated strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- entry-server.tsx uses resolveRoute:
  - RouteType.Redirect returns 302 with Location (single-content
    root -> /<dir>; version root -> /<v>/<dir>)
  - RouteType.DocsPage loads page + version-scoped tree via
    getPageTreeForVersion; missing page -> 404
  - RouteType.DocsIndex returns 404 today (multi-content landing
    lands in phase 3B)
  - API routes load specs only when the URL is actually an API
    route instead of on every request
- PageProvider now takes initialVersion and exposes it via context;
  client nav re-runs resolveRoute on pathname changes so the version
  ctx stays in sync
- App.tsx delegates to the shared resolver and updates Head/JSON-LD
  references to read config.site.title (schema change from phase 1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- entry-client now forwards embedded.version into PageProvider and
  uses the shared resolveRoute to decide when to fetch /api/specs
  (previously hard-coded to pathname.startsWith('/apis'), which
  missed /<v>/apis)
- PageProvider only pushes version state when the route actually
  carries one (Redirect has no version payload)
- Default config fallback matches the new {site, content[]} shape

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- getLandingEntries(config, versionDir) returns {label, href,
  contentDir} per content root (null = latest) so both UI and
  tests share the same source of truth
- LandingPage renders a grid of cards from getLandingEntries; it
  reads version via usePageContext so a versioned /<v> root
  (multi-content) shows that version's dirs + labels
- App.tsx routes RouteType.DocsIndex to LandingPage inside
  DocsLayout; entry-server returns 200 and PageProvider stops
  forcing a 404 for docs-index
- Tests: getLandingEntries covers latest, versioned, and unknown
  version cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- getApiConfigsForVersion(config, dir|null) picks config.api for
  latest or versions[].api for a version; tests cover both
- /api/specs accepts ?version=<dir>; entry-server and entry-client
  pass the active version so /apis and /<v>/apis resolve to their
  own spec set
- page-context always refetches on api-nav so switching versions
  from the api page updates the spec list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- buildLlmsTxt(config, pages, version) centralises the llms.txt
  rendering so /llms.txt and /<v>/llms.txt share one codepath
- /llms.txt now scopes to LATEST_CONTEXT via getPagesForVersion
- New /<version>/llms.txt handler looks up the version from config,
  404s for unknown, and emits pages from getPagesForVersion(ctx)
- sitemap.xml iterates getAllVersions and emits /<v>/apis/... for
  each version's api specs (latest stays unprefixed)
- og.tsx reads config.site.title (schema change from phase 1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- head.tsx, default/paper Layout.tsx, ApiPage.tsx read config.site.title
- init.ts defaultConfig matches the new {site, content[]} schema and
  scaffolds content/<dir>/index.mdx so chronicle dev finds the mirror
  entry; drops --content flag that no longer maps to the config shape
- [version]/llms.txt.ts reads the version param via h3's
  getRouterParam (nitro doesn't re-export it)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
description is now a site-level field in chronicleConfigSchema.
Callers (llms.ts, LandingPage, App Head/JSON-LD, init template,
page-context fallback) read config.site.description.

BREAKING CHANGE: top-level `description` is no longer accepted; move
it under `site:` in chronicle.yaml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Content moved under docs/content/docs/; chronicle.yaml uses
  {site, content[]} shape with description nested under site
- docs URL shape unchanged (/docs/<slug>) since content dir is `docs`
  and existing cross-links already use /docs/ prefix

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runtime-only — no filesystem writes. buildFiles() emits a meta entry
for each (version, contentDir) pair using the config label and
root:true so fumadocs treats the folder as a root and renders its
children at the top of the sidebar instead of wrapping them under
the folder index's frontmatter title.

User-provided meta.json files at the same path still win (synthetic
is skipped when a user entry exists).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- getActiveContentDir(url, config) extracts the active content dir
  from the URL after stripping any version prefix
- getVersionHomeHref(config, versionDir) returns the href a version
  switcher should point to — single content collapses to /<dir>,
  multi content returns the version root so the landing renders
- splitContentButtons(items, max) splits a list into visible +
  overflow for the default theme's 3-button-plus-more layout

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- VersionSwitcher: Apsara Menu triggered from a navbar Button; shows
  active version's label + optional Badge, hides when no versions
- ContentDirButtons: first 3 content dirs render as Buttons (active =
  solid, rest = outline); overflow collapses into a More Menu; hides
  when the active version has <= 1 content dir
- Both hook into usePageContext for the active version and navigate
  with react-router; neither component adds state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Paper theme puts both selectors in the sidebar (not the navbar):

- VersionSwitcher: full-width Apsara Menu; active version label + badge
- ContentDirDropdown: full-width Menu listing config.content[] entries
- Both hide when there are no versions / single content dir
- Layout stacks them above ChapterNav with a new .nav group

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add optional boolean `landing` to latestSchema and versionSchema
- Route resolver checks the flag first: landing=true -> DocsIndex,
  otherwise redirects to the first content dir (default behaviour
  when the field is absent)
- Resolver tests updated: multi-content-no-landing now redirects,
  new case covers the default-redirect path, versioned fixture sets
  landing:true on v1 to keep the DocsIndex assertion

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chronicle Ready Ready Preview, Comment Apr 21, 2026 10:32am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 21, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added multi-version documentation support with per-version configuration, landing pages, and deprecated badges.
    • Added version switcher and content directory navigation UI for improved site exploration.
    • Introduced landing pages for site and version roots.
    • Enabled LLM integration with version-aware llms.txt generation.
    • Enhanced search to be scoped to active documentation version.
  • Configuration

    • Restructured configuration schema with new site object and explicit content array for content directory labels.
  • Documentation

    • Added comprehensive configuration documentation and runnable examples.

Walkthrough

Restructures Chronicle to support versioned documentation by refactoring configuration schema from top-level fields to nested site object, replacing single content string with array of { dir, label } entries, and adding latest/versions support with per-version content, landing pages, and API configuration. Implements version-aware routing, content mirroring, search, and LLM endpoints alongside new theme components for version/content switching.

Changes

Cohort / File(s) Summary
Configuration Schema & Types
packages/chronicle/src/types/config.ts, docs/chronicle.yaml, examples/basic/chronicle.yaml, examples/versioned/chronicle.yaml, docs/content/docs/configuration.mdx
Introduced Zod-based schema validation with nested site object, required content array, optional latest/versions with per-version structure. Added directory uniqueness constraints and conditional latest requirement. Updated example configs to reflect new structure; expanded configuration documentation with multi-version semantics.
CLI Configuration & Loading
packages/chronicle/src/cli/utils/config.ts, packages/chronicle/src/cli/commands/build.ts, packages/chronicle/src/cli/commands/dev.ts, packages/chronicle/src/cli/commands/start.ts, packages/chronicle/src/cli/commands/serve.ts
Removed --content CLI option across all commands. Replaced contentDir with projectRoot (derived from config path directory). Updated loadCLIConfig to remove content resolution logic; threads projectRoot and config to content linking instead.
Initialization & Scaffolding
packages/chronicle/src/cli/commands/init.ts, packages/chronicle/src/cli/commands/init.test.ts, packages/chronicle/src/cli/utils/scaffold.ts, packages/chronicle/src/cli/utils/scaffold.test.ts
Refactored runInit to return typed InitEvent[] for filesystem operations. Replaced single .content symlink with buildContentMirror that recursively mirrors content directories via symlinks. Added comprehensive test coverage for initialization and content mirroring.
Configuration Library & Helpers
packages/chronicle/src/lib/config.ts, packages/chronicle/src/lib/config.test.ts
Schema-driven parsing replacing manual merging. Added helpers: getLatestContentRoots, getVersionContentRoots, getLandingEntries, getApiConfigsForVersion, getAllVersions. Introduced ContentRoot, VersionDescriptor, LandingEntry types.
Route Resolution & Versioning
packages/chronicle/src/lib/route-resolver.ts, packages/chronicle/src/lib/route-resolver.test.ts, packages/chronicle/src/lib/version-source.ts, packages/chronicle/src/lib/version-source.test.ts
New modules for version-aware routing. resolveRoute maps pathname to typed routes (Redirect/DocsIndex/DocsPage/ApiIndex/ApiPage). Version resolution from URL with fallback to latest. Page/tree filtering by version and content directory.
Source & Page Context
packages/chronicle/src/lib/source.ts, packages/chronicle/src/lib/page-context.tsx, packages/chronicle/src/lib/navigation.ts, packages/chronicle/src/lib/navigation.test.ts
Added version-aware page/tree APIs: getPageTreeForVersion, getPagesForVersion. Extended PageContextValue with version field. Navigation helpers: getActiveContentDir, getVersionHomeHref, splitContentButtons.
Server-Side Rendering & Routes
packages/chronicle/src/server/entry-server.tsx, packages/chronicle/src/server/entry-client.tsx, packages/chronicle/src/server/App.tsx, packages/chronicle/src/server/vite-config.ts
Replaced pathname-based routing with resolveRoute(). Added redirect handling for route type redirects. Version-aware API/docs loading. Updated EmbeddedData to include version. Changed createViteConfig to compute .content mirror path from packageRoot.
API & Search Handlers
packages/chronicle/src/server/api/search.ts, packages/chronicle/src/server/api/specs.ts, packages/chronicle/src/server/routes/llms.txt.ts, packages/chronicle/src/server/routes/og.tsx, packages/chronicle/src/server/routes/sitemap.xml.ts
Implemented version-aware search caching and indexing. API specs loading now reads version query param for version-scoped specs. Added /[version]/llms.txt.ts route alongside latest-only /llms.txt.ts. Updated OG image and sitemap generation to include version prefixes and use config.site.title.
Theme Components & Layouts
packages/chronicle/src/themes/default/Layout.tsx, packages/chronicle/src/themes/default/ContentDirButtons.tsx, packages/chronicle/src/themes/default/VersionSwitcher.tsx, packages/chronicle/src/themes/paper/Layout.tsx, packages/chronicle/src/themes/paper/Layout.module.css, packages/chronicle/src/themes/paper/ContentDirDropdown.tsx, packages/chronicle/src/themes/paper/VersionSwitcher.tsx
Added version and content directory UI components. ContentDirButtons/ContentDirDropdown render landing entries with max visible/overflow behavior. VersionSwitcher displays active version with badge and dropdown navigation. Layouts accept hideSidebar prop for landing pages. Updated header references from config.title to config.site.title.
Page Components & Documentation
packages/chronicle/src/pages/LandingPage.tsx, packages/chronicle/src/pages/LandingPage.module.css, packages/chronicle/src/pages/DocsLayout.tsx, packages/chronicle/src/pages/ApiPage.tsx, packages/chronicle/src/components/ui/search.tsx
New LandingPage component rendered at docs root with version-aware heading. DocsLayout filters page tree by active content directory. Search component now reads version.dir for version-scoped search tagging. Landing pages use filtered content sidebar.
LLMs Support
packages/chronicle/src/lib/llms.ts, packages/chronicle/src/lib/llms.test.ts
New buildLlmsTxt function generates markdown index with version-aware headings and link path mapping. Supports per-version label inclusion in output headers.
Configuration & Build
package.json, packages/chronicle/package.json, vercel.json, .github/workflows/ci.yml
Added npm scripts for dev:examples:basic/versioned and build:examples:basic/versioned. Added test script running bun test. Updated Vercel output directory to docs/.vercel/output. Added CI workflow for linting and testing on PR/push to main.
Example Documentation
examples/versioned/chronicle.yaml, examples/versioned/content/docs/*, examples/versioned/content/dev/*, examples/versioned/versions/v1/*, examples/versioned/versions/v2/*
New versioned example site with configuration showing latest (3.0) and historic versions (2.0, 1.0) with separate content directories, landing flags, and deprecated badge on v1.0.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant Server as Server<br/>(Vite/Nitro)
    participant RouteResolver as Route Resolver
    participant ConfigLib as Config Lib
    participant ContentSys as Content System
    participant PageDB as Page Database

    Client->>Server: GET /v2/docs/api
    Server->>RouteResolver: resolveRoute(pathname, config)
    RouteResolver->>ConfigLib: resolveVersionFromUrl(url, config)
    ConfigLib-->>RouteResolver: VersionContext { dir: "v2", urlPrefix: "/v2" }
    RouteResolver-->>Server: Route { type: DocsPage, version, slug: ["api"] }
    
    Server->>ConfigLib: getPageTreeForVersion(version)
    ConfigLib->>ContentSys: filterPageTreeByVersion(tree, version)
    ContentSys-->>ConfigLib: filtered tree (v2 only)
    ConfigLib-->>Server: page tree
    
    Server->>PageDB: fetch page for slug["api"]
    PageDB-->>Server: page data { title, content, version }
    
    Server-->>Client: HTML with version context

    rect rgba(100, 150, 200, 0.5)
    Note over Client,PageDB: Version-aware navigation
    Client->>Server: GET /v2
    Server->>RouteResolver: resolveRoute("/v2", config)
    RouteResolver-->>Server: Route { type: DocsIndex, version: {dir: "v2"} }
    Server->>ConfigLib: getLandingEntries(config, "v2")
    ConfigLib-->>Server: landing entries for v2
    Server-->>Client: LandingPage with v2 content dirs
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • rohanchkrabrty
  • rohilsurana
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: multi-content + versioned docs' accurately and concisely describes the main feature additions of this pull request.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the new configuration shape, filesystem layout, content mirroring, routing, versioning, and all major features being introduced.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat_multi_content_support

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

rsbh and others added 2 commits April 21, 2026 15:09
Add hideSidebar flag to ThemeLayoutProps; both theme Layouts skip
their sidebar when set. App.tsx passes hideSidebar=true for the
DocsIndex (landing) route so /<...> landing pages render without
the sidebar chrome while keeping navbar/footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
latest + v1 set landing:true so / and /v1 show the chromeless
landing; v2 leaves it default so /v2 redirects to /v2/docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rsbh and others added 12 commits April 21, 2026 15:11
config.ts runs both server- and client-side (imported by
LandingPage/ContentDirButtons via getLandingEntries).
Vite externalises node:path on the client, so path.join(...) threw
at runtime. Replace with forward-slash template literals; scaffold
still normalises via path.resolve when it consumes fsPath.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apsara 0.55 exposes DropdownMenu, not Menu. Replace Menu + render
prop with DropdownMenu + asChild pattern in both themes' switchers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a pure helper that trims the page tree down to a single
content folder's children given the active (version, contentDir)
pair. DocsLayout already calls this so the sidebar at /docs shows
only docs pages, /dev only dev pages, etc.

Tests cover latest, missing content dir, and versioned disambiguation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /api/search reads a `tag` param (used by fumadocs' fetchClient) to
  resolve a VersionContext; unknown/missing tag falls back to latest
- Pages come from getPagesForVersion, api docs from
  getApiConfigsForVersion + the version's urlPrefix so /apis entries
  for v1 link to /v1/apis/...
- Indexes and raw docs cached per version key so switching versions
  doesn't rebuild latest and vice-versa
- Search component pulls active version from usePageContext and
  forwards it as `tag` to useDocsSearch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Head now reads pathname via useLocation and emits
<link rel='canonical'> plus og:url when config.url is set. Since
pathname already carries the version prefix, versioned pages get
distinct canonical URLs (e.g. https://site.example.com/v1/docs/intro).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactor init into a pure runInit(projectDir) that returns a list of
events (created/skipped/updated); the Command only wires stdout. This
makes init unit-testable.

Tests cover:
- empty projects scaffolding content/docs, chronicle.yaml (with a
  sample index.mdx), and .gitignore
- emitted chronicle.yaml round-tripping through chronicleConfigSchema
- existing chronicle.yaml is left untouched
- existing content/docs with files skips the sample index.mdx
- .gitignore with partial entries gets the missing ones appended

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- chronicle.yaml uses {site, content[]} shape; title -> site.title,
  description -> site.description, content string removed in favour of
  the `docs` content root
- All mdx moved into content/docs/ (api/, guides/, etc. preserved
  under the same dir); petstore.json and frontier.yaml stay at the
  project root so config.api spec paths keep resolving

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Adds project-layout section showing content/ + versions/ roots
- Drops removed top-level title/description/content-string, adds new
  site, content[], latest, versions sections with landing + badge
- Notes search scoping per version and llms.txt per-version output

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step-by-step walk-through for upgrading a legacy Chronicle project:
before/after config and filesystem diffs, content move, optional
landing + version setup, breaking-change table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration notes stay in the local VERSIONING_MIGRATION.md instead of
shipping as a docs page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs bun lint and bun test on every pull request and push to main
from packages/chronicle. Uses the same Bun runtime as the release
workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rsbh rsbh changed the title Feat multi content support feat: multi-content + versioned docs Apr 21, 2026
rsbh and others added 2 commits April 21, 2026 15:51
Vite's build-time import.meta.glob doesn't descend into symlinked
directories, so the previous dir-level symlinks made the production
bundle ship an empty page tree (/docs etc. 404). Replace
buildContentMirror's dir symlinks with a recursive walk that mkdirs
each subfolder in the mirror and symlinks files individually; dev
live-reload is preserved, and vite build now walks real dirs.

Tests updated to assert on real dirs + per-file symlinks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The start command lost --config in the phase-2 CLI refactor; pass
the user's chronicle.yaml path through loadCLIConfig so
npm-script workflows like `chronicle start --config docs/...`
resolve the right config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rsbh and others added 2 commits April 21, 2026 15:59
Content + version dir names now fail schema validation unless they
are simple folder names. Rejects '.', '..', and anything containing
'/' or '\\' so neither fsPath nor urlPrefix can resolve to a path
traversal or produce a broken URL like '/./.'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rsbh rsbh marked this pull request as ready for review April 21, 2026 10:33
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 21, 2026

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{}

@rsbh rsbh requested a review from rohilsurana April 21, 2026 10:33
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/chronicle/src/cli/utils/config.ts (1)

32-43: ⚠️ Potential issue | 🟡 Minor

Handle malformed YAML before schema validation.

parse(raw) can throw, which bypasses the formatted Invalid chronicle.yaml error path and can surface a stack trace for simple YAML syntax mistakes.

Suggested fix
 function validateConfig(raw: string, configPath: string): ChronicleConfig {
-  const parsed = parse(raw);
+  let parsed: unknown;
+  try {
+    parsed = parse(raw);
+  } catch (error) {
+    console.log(chalk.red(`Error: Invalid chronicle.yaml at '${configPath}'`));
+    console.log(chalk.gray(error instanceof Error ? error.message : String(error)));
+    process.exit(1);
+  }
+
   const result = chronicleConfigSchema.safeParse(parsed);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/cli/utils/config.ts` around lines 32 - 43, The
validateConfig function currently calls parse(raw) which can throw on malformed
YAML and bypass the friendly error handling; wrap the parse(raw) call in a
try/catch inside validateConfig, and on catch log the same formatted "Error:
Invalid chronicle.yaml at '<configPath>'" message (including the parsing error
message as context) and exit(1); then proceed to run
chronicleConfigSchema.safeParse on the parsed result if parse succeeds. Ensure
you reference the validateConfig function and the parse(raw) invocation when
making the change.
packages/chronicle/src/server/routes/sitemap.xml.ts (1)

40-43: ⚠️ Potential issue | 🟡 Minor

Include only configured landing routes in the sitemap.

Line 42 always emits ${baseUrl} but does not emit version landing URLs like ${baseUrl}/v1 when a non-latest version has landing: true. This makes version landing pages undiscoverable in the sitemap and can include a root URL even when latest landing is disabled.

Proposed fix
+  const landingPages = getAllVersions(config)
+    .filter(v => v.landing)
+    .map(v =>
+      `<url><loc>${baseUrl}${v.isLatest ? '' : `/${v.dir}`}</loc></url>`
+    );
+
   const xml = `<?xml version="1.0" encoding="UTF-8"?>
 <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
-<url><loc>${baseUrl}</loc></url>
-${[...docPages, ...apiPages].join('\n')}
+${[...landingPages, ...docPages, ...apiPages].join('\n')}
 </urlset>`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/routes/sitemap.xml.ts` around lines 40 - 43,
The sitemap always emits the root URL unconditionally; change the construction
of xml (the xml template string) so it does not unconditionally include
`${baseUrl}` and instead includes only configured landing routes: include
`${baseUrl}` only if the latest version has landing enabled, and append a
landing URL `${baseUrl}/${versionName}` for each version where `landing: true`;
keep concatenating `${[...docPages, ...apiPages].join('\n')}` as before. Locate
the xml template string and the version/config object used to generate pages,
remove the hard-coded `${baseUrl}` line, and generate landing entries
programmatically from your versions list (referencing the same version
identifier used elsewhere) before joining with docPages and apiPages.
🧹 Nitpick comments (10)
packages/chronicle/src/types/config.ts (1)

163-166: Minor: latest gate allows versions: [].

!cfg.versions || cfg.versions.length === 0 || !!cfg.latest intentionally treats an empty versions array as "no versions", so a user can ship versions: [] with no latest block. That's probably fine, but it does mean an empty array is a silently supported way to disable versioning despite the key being declared. If that's not desired, drop the length === 0 short-circuit so empty arrays are also required to declare latest (or better, tell users to omit the key entirely).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/types/config.ts` around lines 163 - 166, The current
Zod refinement on the schema (.refine((cfg) => !cfg.versions ||
cfg.versions.length === 0 || !!cfg.latest, { ... })) treats an empty versions
array as "no versions" and therefore doesn't require latest; remove the length
=== 0 short-circuit so the predicate becomes !cfg.versions || !!cfg.latest (or
equivalently require latest whenever cfg.versions is present), i.e., update the
.refine predicate that references cfg.versions and cfg.latest to not treat [] as
absent so empty arrays will also require a latest block.
.github/workflows/ci.yml (1)

20-25: Optional: pin Bun version and cache dependencies for reproducible, faster CI.

oven-sh/setup-bun@v2 without bun-version will pull whichever Bun is current at run time, which can cause silent drift between local and CI. Consider pinning (e.g., bun-version: 1.2.x or bun-version-file) and enabling caching for ~/.bun/install/cache to speed up bun install.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ci.yml around lines 20 - 25, Pin the Bun version in the
"Setup Bun" step by adding a bun-version or bun-version-file input (so the
oven-sh/setup-bun action uses a fixed version) and enable caching of Bun and
dependencies for the "Install dependencies" step by caching ~/.bun/install/cache
(and optionally node_modules or bun.lockb artifacts) so bun install
--frozen-lockfile runs reproducibly and faster in CI; update the action inputs
for the "Setup Bun" step and add a cache step that keys off the bun version and
lockfile to restore/save ~/.bun/install/cache before/after running bun install.
packages/chronicle/src/lib/llms.test.ts (1)

6-67: Nice coverage of the three heading paths.

The three cases (latest label present, latest absent, specific version) plus the //index.md and urlPrefix rewriting assertions give good coverage of buildLlmsTxt's formatting contract. Consider adding one more case for a page whose url already ends in a trailing slash (e.g., /docs/a/) if that's a shape the caller can produce, to lock down the .md suffixing rule.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/lib/llms.test.ts` around lines 6 - 67, Add a test case
in packages/chronicle/src/lib/llms.test.ts that calls buildLlmsTxt with a page
whose url ends with a trailing slash (e.g., '/docs/a/') and assert the output
contains the link with a .md suffix (e.g., '- [A](/docs/a.md)'); place it
alongside the other describe('buildLlmsTxt') tests and reuse LATEST_CONTEXT or a
versioned ctx as appropriate to also validate urlPrefix rewriting when
applicable.
packages/chronicle/src/server/api/search.ts (2)

53-82: Minor: redundant loadConfig() calls per request.

loadConfig() is invoked in both resolveCtx and buildApiDocs on every request. If loadConfig isn't memoized internally, this is wasteful — and if it ever becomes async/fs-bound, it'll compound. Consider accepting config as a parameter (threaded from the handler) or caching it at module scope.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/api/search.ts` around lines 53 - 82, The
buildApiDocs function calls loadConfig() every request; remove that redundant
call by adding a config parameter to buildApiDocs (e.g., buildApiDocs(ctx:
VersionContext, config: ConfigType)) and use that config with
getApiConfigsForVersion instead of calling loadConfig(); then update all call
sites (the request handler/resolveCtx caller that currently invokes
buildApiDocs) to loadConfig once (or use a module-scope cached config) and pass
the config into buildApiDocs; ensure the function signature, references to
getApiConfigsForVersion, and any tests or imports are updated accordingly.

18-23: Confirm cache invalidation story in dev mode.

indexCache / docsCache are module-level Maps with no eviction. In production SSR this is fine, but in dev (Vite HMR) when a page's frontmatter or a new page is added, the cached docs/index for a given version won't refresh until the process restarts. There's no file watcher or HMR handler currently clearing these caches. Consider exposing a way to bust the cache (e.g., watcher-driven cache.clear()) to improve the dev workflow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/api/search.ts` around lines 18 - 23, indexCache
and docsCache are global Maps that never evict, causing stale search/index state
during dev with Vite HMR; add a cache-busting mechanism and wire it to dev
file-watcher/HMR. Implement and export a small API (e.g., clearSearchCaches or
clearIndexAndDocsCache) that calls indexCache.clear() and docsCache.clear()
and/or a per-key invalidator that uses keyFor(ctx) to remove a specific entry;
then call that function from your dev watcher/HMR handler (or when
frontmatter/page files change) so updated pages rebuild their MiniSearch index
and docs array without restarting the process. Ensure the new function is
reachable where the dev server/file-watcher logic runs and only invoked in
dev/HMR scenarios.
packages/chronicle/src/themes/default/ContentDirButtons.tsx (1)

23-61: Inconsistent navigation between visible buttons and overflow items.

Visible entries use <RouterLink> (client-side SPA navigation) while overflow items in the dropdown call navigate(entry.href) via onClick. Functionally both are SPA navigations, but the <RouterLink> wrapper on visible buttons also gives users native affordances (middle-click / cmd-click to open in new tab, right-click → copy link, hover preview). Overflow items lose all of these because they are not real anchors.

Consider rendering overflow items as anchor-based items (e.g., <DropdownMenu.Item asChild><RouterLink to={entry.href}>…</RouterLink></DropdownMenu.Item>, if supported) so the behavior is consistent and keyboard/mouse users retain link semantics.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/themes/default/ContentDirButtons.tsx` around lines 23
- 61, The visible buttons use RouterLink for SPA navigation (giving anchor
semantics like middle-click, right-click, copy link) while overflow items call
navigate(entry.href) in DropdownMenu.Item onClick, losing those affordances;
change the overflow rendering to render anchor-based items by wrapping each
DropdownMenu.Item with asChild and placing a RouterLink (to={entry.href}) inside
(replace the onClick navigate usage), so overflow entries use the same
RouterLink semantics as the visible entries (refer to RouterLink,
DropdownMenu.Item, overflow, visible, and navigate in the current diff).
packages/chronicle/src/lib/llms.ts (1)

21-26: Page titles are not escaped for Markdown.

p.title is interpolated directly inside [${p.title}](${mdUrl}). Frontmatter titles legitimately containing ], [, or \ will break the link syntax (and a URL containing ) — uncommon but possible — would break the target). Consider escaping ] / \ in the title and URL-encoding the path, or at minimum documenting the constraint in LlmsPage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/lib/llms.ts` around lines 21 - 26, Escape
Markdown-sensitive characters in page titles and URL-encode the path before
building the index string: when mapping over pages (the block that computes
index from pages, using p.title and mdUrl) replace p.title with an escaped
variant that backslash-escapes backslashes and square brackets (e.g., escape
backslash '\' and ']' and '[') and replace mdUrl with a URL-encoded form (use
encodeURI/encodeURIComponent on the path or filename) so the template becomes "-
[escapedTitle](encodedMdUrl)"; update the mapping in llms.ts where index is
computed to use these escaped/encoded values.
packages/chronicle/src/themes/paper/VersionSwitcher.tsx (1)

40-54: Dropdown items navigate via onClick only — loses anchor semantics and duplicates default theme.

Same concern as ContentDirButtons: the items are not real anchors, so middle-click / cmd-click / right-click-copy-link don’t work. Also, this file is essentially a byte-for-byte duplicate of packages/chronicle/src/themes/default/VersionSwitcher.tsx apart from the width='100%' on the trigger button. Consider extracting a shared VersionSwitcher (or a shared hook/useVersionSwitcher()) in @/lib and letting each theme pass its minor visual overrides, to avoid drift as the component evolves.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/themes/paper/VersionSwitcher.tsx` around lines 40 -
54, The DropdownMenu items in VersionSwitcher are using onClick navigation
(versions.map -> DropdownMenu.Item with onClick={() =>
navigate(getVersionHomeHref(...))}), which loses native anchor semantics and
duplicates logic from the default theme; change each DropdownMenu.Item to render
as a real anchor by passing an "as" prop or an href (use
getVersionHomeHref(config, v.dir) as the href) so
middle-click/CMD-click/right-click work, keep navigate fallback if needed for
client routing, and then extract the shared logic into a single shared
VersionSwitcher (or useVersionSwitcher() hook) in the lib so both themes import
it and only pass visual overrides (e.g., trigger button width='100%') to avoid
drift between packages/chronicle/src/themes/paper/VersionSwitcher.tsx and
packages/chronicle/src/themes/default/VersionSwitcher.tsx.
packages/chronicle/src/pages/LandingPage.module.css (1)

1-56: LGTM.

Styles are straightforward CSS-module tokens. Optional: consider a focus-visible style on .card that matches :hover — currently keyboard users get browser-default focus only, which may not be very visible on the card background.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/pages/LandingPage.module.css` around lines 1 - 56,
Keyboard users don't get a visible custom focus state on the card; add a
matching focus-visible rule so .card elements show the same visual treatment as
.card:hover. Update the CSS to include a .card:focus-visible selector (and/or
.card:focus when needed) that sets border-color and background the same as
.card:hover and also add an accessible focus indicator (e.g., outline and
outline-offset) so keyboard-only users see the card is focused; ensure this
applies to the same focusable elements using the .card class (e.g., anchor
cards).
packages/chronicle/src/cli/commands/init.ts (1)

62-73: .gitignore substring match can both over- and under-match.

existing.includes('dist') returns true for lines like distribution, build/dist, or # dist (commented), silently skipping an entry the user actually needs. Conversely, if the existing file lacks a trailing newline, an entry can be glued onto the previous line. Compare against trimmed, non-comment lines and normalize the trailing newline before appending.

♻️ Proposed refactor
   const gitignorePath = path.join(projectDir, '.gitignore');
   if (fs.existsSync(gitignorePath)) {
     const existing = fs.readFileSync(gitignorePath, 'utf-8');
-    const missing = GITIGNORE_ENTRIES.filter(e => !existing.includes(e));
+    const existingLines = new Set(
+      existing
+        .split(/\r?\n/)
+        .map(l => l.trim())
+        .filter(l => l && !l.startsWith('#')),
+    );
+    const missing = GITIGNORE_ENTRIES.filter(e => !existingLines.has(e));
     if (missing.length > 0) {
-      fs.appendFileSync(gitignorePath, `\n${missing.join('\n')}\n`);
+      const prefix = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
+      fs.appendFileSync(gitignorePath, `${prefix}${missing.join('\n')}\n`);
       events.push({ type: 'updated', path: gitignorePath, detail: missing.join(', ') });
     }
   } else {
     fs.writeFileSync(gitignorePath, `${GITIGNORE_ENTRIES.join('\n')}\n`);
     events.push({ type: 'created', path: gitignorePath });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/cli/commands/init.ts` around lines 62 - 73, The
.gitignore handling in the init flow can false-match via includes() and can
append without a trailing newline; update the logic around gitignorePath so you
read existing, split into lines, normalize by trimming whitespace and ignoring
lines that start with '#' (comments), then compute missing entries by checking
exact equality against GITIGNORE_ENTRIES (not substring includes) using the
normalized lines; ensure the file content ends with a single newline before
appending the missing entries and update events (in the same block that uses
existing, missing, and events) to record the correct detail string as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/chronicle/src/cli/commands/init.test.ts`:
- Around line 46-51: The test title says it should preserve an existing
content/docs/index.mdx but the fixture only creates content/docs/existing.mdx,
so update the test around runInit to either (a) create content/docs/index.mdx
with distinctive content before calling runInit and then assert that file still
exists and its contents were not modified (use the same path checked now and
read contents to compare), or (b) if you meant to test skipping scaffolding for
any non-empty dir, rename the test to “does not scaffold index.mdx when
content/docs is non-empty” to reflect that behavior; adjust the test setup and
assertions accordingly and keep references to runInit and the
content/docs/index.mdx path to locate the logic under test.

In `@packages/chronicle/src/cli/utils/scaffold.ts`:
- Around line 44-50: The try/catch in mirrorTree (and the similar block around
lines 63-73) is currently swallowing all errors; change them to only suppress
"not found" errors (e.g., check err.code === 'ENOENT' or err.code === 'ENOTDIR'
as appropriate) and rethrow or propagate any other errors (or surface them via
logging) so permission/read/delete issues are not silently ignored; update the
error handling in mirrorTree and the other catch blocks to inspect the caught
error object before deciding to return, referencing the mirrorTree function and
the subsequent directory-iteration/entry-processing try/catch blocks.

In `@packages/chronicle/src/lib/head.tsx`:
- Around line 16-18: The social image URLs are still rendered as relative paths;
when config.url is set you should build absolute URLs the same way as with
canonical: use config.url.replace(/\/$/, '') as the base and prepend it to the
image path (e.g. the `/og?...` image string) before assigning to the og:image
and twitter:image meta values. Update the code that computes the image URL(s)
(referencing the existing canonical computation and the variables used to render
og:image/twitter:image in this file) to avoid double slashes and return the
absolute URL when config.url is truthy, otherwise keep the relative path.

In `@packages/chronicle/src/lib/llms.ts`:
- Around line 19-28: The template always emits extra blank lines when
config.site.description is empty; update the construction around const
description, const index and the final return so the description is only
included when non-empty (e.g. build an array of sections [heading, description,
index] and filter out falsy values before joining with '\n\n'), ensuring
llms.txt does not get stray blank lines.

In `@packages/chronicle/src/lib/navigation.ts`:
- Around line 17-23: The code computes dirs by directly accessing config.content
or config.versions?.find(...).content.map(...), which bypasses the config helper
logic and can crash when a version inherits/defaults; replace this ternary with
a call to the config helper that returns the effective content roots for a
version (e.g., use the project config function that computes version content
roots) and assign its result to dirs (falling back to []). Update the code
around dirs, version.dir, config.content and config.versions to use that helper
so inherited/default content is respected and null safety is preserved.

In `@packages/chronicle/src/lib/page-context.tsx`:
- Around line 105-118: When handling API routes (route.type ===
RouteType.ApiIndex || route.type === RouteType.ApiPage) the function returns
early before clearing stale page and error state, which can leave old
page/errorStatus visible during client navigation; update the branch that
fetches /api/specs to first clear/reset page and errorStatus (call the same
state setters you use elsewhere to set page to null/empty and errorStatus to
undefined/0 as appropriate), then proceed to kick off the fetch and setApiSpecs
with cancelled.current handling unchanged, ensuring the return cleanup still
sets cancelled.current = true; reference route.type, RouteType.ApiIndex,
RouteType.ApiPage, setApiSpecs, cancelled.current, and the state setters for
page and errorStatus when making the change.

In `@packages/chronicle/src/lib/route-resolver.ts`:
- Around line 21-28: The function contentDirsFor is directly reading v?.content
instead of using the already-resolved content on the VersionContext or falling
back safely, which breaks versions that rely on helper/default content; update
contentDirsFor to first use version.content when present (map over
version.content to return dir), then if version.dir === null fall back to
config.content.map(...), and only then try to find the config version
(config.versions?.find(...)) and safely map its content (guarding against
undefined) so you never call .map on undefined; reference the function
contentDirsFor, the parameter VersionContext (version.content), and
config.versions in your change.

In `@packages/chronicle/src/pages/LandingPage.tsx`:
- Around line 20-25: The card links on LandingPage.tsx use a native <a> (inside
entries.map) causing full page reloads; replace those anchors with the app's
RouterLink component (same props: key, to={entry.href}, className etc.) and
preserve the inner spans (styles.cardLabel and styles.cardHref) so navigation
stays client-side like ContentDirButtons; ensure imports include RouterLink and
remove or update any href usage to to so the SPA routing and in-memory state are
preserved.

In `@packages/chronicle/src/server/api/search.ts`:
- Around line 107-113: resolveCtx currently returns LATEST_CONTEXT for any
unknown tag which can silently mix versions; change resolveCtx (the function
using VersionContext, loadConfig and LATEST_CONTEXT) so that when a non-null tag
is passed but no matching version is found it returns null (or a distinct
sentinel) and also emits a warning log identifying the unknown tag; then update
the caller/handler that consumes resolveCtx to detect null and return an empty
result or 404 rather than falling back to latest.

In `@packages/chronicle/src/server/entry-server.tsx`:
- Around line 27-32: The early return for RouteType.Redirect in entry-server.tsx
skips emitting the SSR telemetry hook; before returning the Response for the
redirect, call useNitroApp().hooks.callHook('chronicle:ssr-rendered', { status:
route.status, renderDuration: 0, /* include same payload fields used elsewhere
*/ }) (await the call if other code expects it) so redirects are recorded with
renderDuration = 0 and the redirect status, preserving the same payload shape as
non-redirect responses.

In `@packages/chronicle/src/server/routes/llms.txt.ts`:
- Around line 14-22: buildLlmsTxt is inserting page titles with `${p.title}` but
callers pass extractFrontmatter(p).title which can be undefined; update
buildLlmsTxt (or the caller mapping) to avoid `- [undefined](...)` by either
skipping pages without a title or providing a fallback (e.g., use page slug or
url path). Locate the pages mapping (where getPagesForVersion(LATEST_CONTEXT) is
used and pages.map(p => ({ url: p.url, title: extractFrontmatter(p).title })))
and change it so title is always a string before handing to buildLlmsTxt (e.g.,
title: extractFrontmatter(p).title ?? deriveSlugFromUrl(p.url)) or modify
buildLlmsTxt to treat undefined titles the same way and omit or substitute them.
Ensure references to buildLlmsTxt and extractFrontmatter remain intact and
tests/llms output no longer contain "undefined".

In `@packages/chronicle/src/types/config.ts`:
- Around line 82-87: dirNameSchema is too permissive: tighten it to reject
leading-dot names, surrounding/embedded whitespace, control/NUL chars, and
OS-reserved device names (e.g., CON, PRN) so mirror conflicts in
buildContentMirror are avoided; update the validation on dirNameSchema to
enforce a conservative allowed charset (letters, digits, hyphen, underscore, and
optionally a single dot not at start), trim/forbid whitespace, disallow
control/NUL characters, and explicitly reject common reserved names (CON, PRN,
AUX, NUL, COM1...COM9, LPT1...LPT9) and any name starting with '.' so invalid
values are caught at schema validation time rather than during filesystem
operations.
- Around line 127-166: chronicleConfigSchema currently allows top-level
content.dir values to collide with versions[].dir and reserved route segments
(e.g., "apis"), causing routing shadowing in resolveVersionFromUrl; add a
.refine on chronicleConfigSchema that (1) collects all top-level content dirs
and all version dirs and fails if any value appears in both, and (2) fails if
any content.dir or version.dir is one of the reserved route names (e.g.,
"apis"); provide a clear error message like 'content[].dir conflicts with
versions[].dir or reserved route segments' and set the refine path to
['content'] or ['versions'] as appropriate so the validation rejects configs
with overlapping or reserved directory names.

---

Outside diff comments:
In `@packages/chronicle/src/cli/utils/config.ts`:
- Around line 32-43: The validateConfig function currently calls parse(raw)
which can throw on malformed YAML and bypass the friendly error handling; wrap
the parse(raw) call in a try/catch inside validateConfig, and on catch log the
same formatted "Error: Invalid chronicle.yaml at '<configPath>'" message
(including the parsing error message as context) and exit(1); then proceed to
run chronicleConfigSchema.safeParse on the parsed result if parse succeeds.
Ensure you reference the validateConfig function and the parse(raw) invocation
when making the change.

In `@packages/chronicle/src/server/routes/sitemap.xml.ts`:
- Around line 40-43: The sitemap always emits the root URL unconditionally;
change the construction of xml (the xml template string) so it does not
unconditionally include `${baseUrl}` and instead includes only configured
landing routes: include `${baseUrl}` only if the latest version has landing
enabled, and append a landing URL `${baseUrl}/${versionName}` for each version
where `landing: true`; keep concatenating `${[...docPages,
...apiPages].join('\n')}` as before. Locate the xml template string and the
version/config object used to generate pages, remove the hard-coded `${baseUrl}`
line, and generate landing entries programmatically from your versions list
(referencing the same version identifier used elsewhere) before joining with
docPages and apiPages.

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 20-25: Pin the Bun version in the "Setup Bun" step by adding a
bun-version or bun-version-file input (so the oven-sh/setup-bun action uses a
fixed version) and enable caching of Bun and dependencies for the "Install
dependencies" step by caching ~/.bun/install/cache (and optionally node_modules
or bun.lockb artifacts) so bun install --frozen-lockfile runs reproducibly and
faster in CI; update the action inputs for the "Setup Bun" step and add a cache
step that keys off the bun version and lockfile to restore/save
~/.bun/install/cache before/after running bun install.

In `@packages/chronicle/src/cli/commands/init.ts`:
- Around line 62-73: The .gitignore handling in the init flow can false-match
via includes() and can append without a trailing newline; update the logic
around gitignorePath so you read existing, split into lines, normalize by
trimming whitespace and ignoring lines that start with '#' (comments), then
compute missing entries by checking exact equality against GITIGNORE_ENTRIES
(not substring includes) using the normalized lines; ensure the file content
ends with a single newline before appending the missing entries and update
events (in the same block that uses existing, missing, and events) to record the
correct detail string as before.

In `@packages/chronicle/src/lib/llms.test.ts`:
- Around line 6-67: Add a test case in packages/chronicle/src/lib/llms.test.ts
that calls buildLlmsTxt with a page whose url ends with a trailing slash (e.g.,
'/docs/a/') and assert the output contains the link with a .md suffix (e.g., '-
[A](/docs/a.md)'); place it alongside the other describe('buildLlmsTxt') tests
and reuse LATEST_CONTEXT or a versioned ctx as appropriate to also validate
urlPrefix rewriting when applicable.

In `@packages/chronicle/src/lib/llms.ts`:
- Around line 21-26: Escape Markdown-sensitive characters in page titles and
URL-encode the path before building the index string: when mapping over pages
(the block that computes index from pages, using p.title and mdUrl) replace
p.title with an escaped variant that backslash-escapes backslashes and square
brackets (e.g., escape backslash '\' and ']' and '[') and replace mdUrl with a
URL-encoded form (use encodeURI/encodeURIComponent on the path or filename) so
the template becomes "- [escapedTitle](encodedMdUrl)"; update the mapping in
llms.ts where index is computed to use these escaped/encoded values.

In `@packages/chronicle/src/pages/LandingPage.module.css`:
- Around line 1-56: Keyboard users don't get a visible custom focus state on the
card; add a matching focus-visible rule so .card elements show the same visual
treatment as .card:hover. Update the CSS to include a .card:focus-visible
selector (and/or .card:focus when needed) that sets border-color and background
the same as .card:hover and also add an accessible focus indicator (e.g.,
outline and outline-offset) so keyboard-only users see the card is focused;
ensure this applies to the same focusable elements using the .card class (e.g.,
anchor cards).

In `@packages/chronicle/src/server/api/search.ts`:
- Around line 53-82: The buildApiDocs function calls loadConfig() every request;
remove that redundant call by adding a config parameter to buildApiDocs (e.g.,
buildApiDocs(ctx: VersionContext, config: ConfigType)) and use that config with
getApiConfigsForVersion instead of calling loadConfig(); then update all call
sites (the request handler/resolveCtx caller that currently invokes
buildApiDocs) to loadConfig once (or use a module-scope cached config) and pass
the config into buildApiDocs; ensure the function signature, references to
getApiConfigsForVersion, and any tests or imports are updated accordingly.
- Around line 18-23: indexCache and docsCache are global Maps that never evict,
causing stale search/index state during dev with Vite HMR; add a cache-busting
mechanism and wire it to dev file-watcher/HMR. Implement and export a small API
(e.g., clearSearchCaches or clearIndexAndDocsCache) that calls
indexCache.clear() and docsCache.clear() and/or a per-key invalidator that uses
keyFor(ctx) to remove a specific entry; then call that function from your dev
watcher/HMR handler (or when frontmatter/page files change) so updated pages
rebuild their MiniSearch index and docs array without restarting the process.
Ensure the new function is reachable where the dev server/file-watcher logic
runs and only invoked in dev/HMR scenarios.

In `@packages/chronicle/src/themes/default/ContentDirButtons.tsx`:
- Around line 23-61: The visible buttons use RouterLink for SPA navigation
(giving anchor semantics like middle-click, right-click, copy link) while
overflow items call navigate(entry.href) in DropdownMenu.Item onClick, losing
those affordances; change the overflow rendering to render anchor-based items by
wrapping each DropdownMenu.Item with asChild and placing a RouterLink
(to={entry.href}) inside (replace the onClick navigate usage), so overflow
entries use the same RouterLink semantics as the visible entries (refer to
RouterLink, DropdownMenu.Item, overflow, visible, and navigate in the current
diff).

In `@packages/chronicle/src/themes/paper/VersionSwitcher.tsx`:
- Around line 40-54: The DropdownMenu items in VersionSwitcher are using onClick
navigation (versions.map -> DropdownMenu.Item with onClick={() =>
navigate(getVersionHomeHref(...))}), which loses native anchor semantics and
duplicates logic from the default theme; change each DropdownMenu.Item to render
as a real anchor by passing an "as" prop or an href (use
getVersionHomeHref(config, v.dir) as the href) so
middle-click/CMD-click/right-click work, keep navigate fallback if needed for
client routing, and then extract the shared logic into a single shared
VersionSwitcher (or useVersionSwitcher() hook) in the lib so both themes import
it and only pass visual overrides (e.g., trigger button width='100%') to avoid
drift between packages/chronicle/src/themes/paper/VersionSwitcher.tsx and
packages/chronicle/src/themes/default/VersionSwitcher.tsx.

In `@packages/chronicle/src/types/config.ts`:
- Around line 163-166: The current Zod refinement on the schema (.refine((cfg)
=> !cfg.versions || cfg.versions.length === 0 || !!cfg.latest, { ... })) treats
an empty versions array as "no versions" and therefore doesn't require latest;
remove the length === 0 short-circuit so the predicate becomes !cfg.versions ||
!!cfg.latest (or equivalently require latest whenever cfg.versions is present),
i.e., update the .refine predicate that references cfg.versions and cfg.latest
to not treat [] as absent so empty arrays will also require a latest block.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9f7afa2f-581a-48ee-af4c-9ca7a0882bdd

📥 Commits

Reviewing files that changed from the base of the PR and between e8d8f04 and fa3ebdb.

📒 Files selected for processing (74)
  • .github/workflows/ci.yml
  • docs/chronicle.yaml
  • docs/content/docs/cli.mdx
  • docs/content/docs/components.mdx
  • docs/content/docs/configuration.mdx
  • docs/content/docs/docker.mdx
  • docs/content/docs/frontmatter.mdx
  • docs/content/docs/index.mdx
  • docs/content/docs/themes.mdx
  • examples/basic/chronicle.yaml
  • examples/basic/content/docs/api/endpoints.mdx
  • examples/basic/content/docs/api/overview.mdx
  • examples/basic/content/docs/getting-started.mdx
  • examples/basic/content/docs/guides/configuration.mdx
  • examples/basic/content/docs/guides/installation.mdx
  • examples/basic/content/docs/index.mdx
  • examples/versioned/chronicle.yaml
  • examples/versioned/content/dev/api.mdx
  • examples/versioned/content/dev/index.mdx
  • examples/versioned/content/docs/guide.mdx
  • examples/versioned/content/docs/index.mdx
  • examples/versioned/versions/v1/dev/index.mdx
  • examples/versioned/versions/v1/docs/index.mdx
  • examples/versioned/versions/v2/docs/guide.mdx
  • examples/versioned/versions/v2/docs/index.mdx
  • package.json
  • packages/chronicle/package.json
  • packages/chronicle/src/cli/commands/build.ts
  • packages/chronicle/src/cli/commands/dev.ts
  • packages/chronicle/src/cli/commands/init.test.ts
  • packages/chronicle/src/cli/commands/init.ts
  • packages/chronicle/src/cli/commands/serve.ts
  • packages/chronicle/src/cli/commands/start.ts
  • packages/chronicle/src/cli/utils/config.ts
  • packages/chronicle/src/cli/utils/scaffold.test.ts
  • packages/chronicle/src/cli/utils/scaffold.ts
  • packages/chronicle/src/components/ui/search.tsx
  • packages/chronicle/src/lib/config.test.ts
  • packages/chronicle/src/lib/config.ts
  • packages/chronicle/src/lib/head.tsx
  • packages/chronicle/src/lib/llms.test.ts
  • packages/chronicle/src/lib/llms.ts
  • packages/chronicle/src/lib/navigation.test.ts
  • packages/chronicle/src/lib/navigation.ts
  • packages/chronicle/src/lib/page-context.tsx
  • packages/chronicle/src/lib/route-resolver.test.ts
  • packages/chronicle/src/lib/route-resolver.ts
  • packages/chronicle/src/lib/source.ts
  • packages/chronicle/src/lib/version-source.test.ts
  • packages/chronicle/src/lib/version-source.ts
  • packages/chronicle/src/pages/ApiPage.tsx
  • packages/chronicle/src/pages/DocsLayout.tsx
  • packages/chronicle/src/pages/LandingPage.module.css
  • packages/chronicle/src/pages/LandingPage.tsx
  • packages/chronicle/src/server/App.tsx
  • packages/chronicle/src/server/api/search.ts
  • packages/chronicle/src/server/api/specs.ts
  • packages/chronicle/src/server/entry-client.tsx
  • packages/chronicle/src/server/entry-server.tsx
  • packages/chronicle/src/server/routes/[version]/llms.txt.ts
  • packages/chronicle/src/server/routes/llms.txt.ts
  • packages/chronicle/src/server/routes/og.tsx
  • packages/chronicle/src/server/routes/sitemap.xml.ts
  • packages/chronicle/src/server/vite-config.ts
  • packages/chronicle/src/themes/default/ContentDirButtons.tsx
  • packages/chronicle/src/themes/default/Layout.tsx
  • packages/chronicle/src/themes/default/VersionSwitcher.tsx
  • packages/chronicle/src/themes/paper/ContentDirDropdown.tsx
  • packages/chronicle/src/themes/paper/Layout.module.css
  • packages/chronicle/src/themes/paper/Layout.tsx
  • packages/chronicle/src/themes/paper/VersionSwitcher.tsx
  • packages/chronicle/src/types/config.ts
  • packages/chronicle/src/types/theme.ts
  • vercel.json

Comment on lines +46 to +51
test('does not overwrite an index.mdx already present in content/docs', () => {
fs.mkdirSync(path.join(tmp, 'content/docs'), { recursive: true })
fs.writeFileSync(path.join(tmp, 'content/docs/existing.mdx'), '# Keep')
runInit(tmp)
expect(fs.existsSync(path.join(tmp, 'content/docs/index.mdx'))).toBe(false)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test name/setup mismatch — doesn’t actually exercise the “already present” path.

The test is titled "does not overwrite an index.mdx already present in content/docs", but the fixture writes content/docs/existing.mdx, never an index.mdx. The assertion expect(fs.existsSync('.../index.mdx')).toBe(false) therefore only verifies that runInit doesn’t create index.mdx when the directory already exists and is non-empty — not that an existing index.mdx is preserved.

If the intended contract is “preserve an existing index.mdx”, please write one first and assert its contents are unchanged:

Proposed fix
   test('does not overwrite an index.mdx already present in content/docs', () => {
     fs.mkdirSync(path.join(tmp, 'content/docs'), { recursive: true })
-    fs.writeFileSync(path.join(tmp, 'content/docs/existing.mdx'), '# Keep')
-    runInit(tmp)
-    expect(fs.existsSync(path.join(tmp, 'content/docs/index.mdx'))).toBe(false)
+    fs.writeFileSync(path.join(tmp, 'content/docs/index.mdx'), '# Keep')
+    runInit(tmp)
+    const contents = fs.readFileSync(
+      path.join(tmp, 'content/docs/index.mdx'),
+      'utf-8',
+    )
+    expect(contents).toBe('# Keep')
   })

If instead the intent is “skip scaffolding when the content dir is non-empty”, rename the test accordingly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not overwrite an index.mdx already present in content/docs', () => {
fs.mkdirSync(path.join(tmp, 'content/docs'), { recursive: true })
fs.writeFileSync(path.join(tmp, 'content/docs/existing.mdx'), '# Keep')
runInit(tmp)
expect(fs.existsSync(path.join(tmp, 'content/docs/index.mdx'))).toBe(false)
})
test('does not overwrite an index.mdx already present in content/docs', () => {
fs.mkdirSync(path.join(tmp, 'content/docs'), { recursive: true })
fs.writeFileSync(path.join(tmp, 'content/docs/index.mdx'), '# Keep')
runInit(tmp)
const contents = fs.readFileSync(
path.join(tmp, 'content/docs/index.mdx'),
'utf-8',
)
expect(contents).toBe('# Keep')
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/cli/commands/init.test.ts` around lines 46 - 51, The
test title says it should preserve an existing content/docs/index.mdx but the
fixture only creates content/docs/existing.mdx, so update the test around
runInit to either (a) create content/docs/index.mdx with distinctive content
before calling runInit and then assert that file still exists and its contents
were not modified (use the same path checked now and read contents to compare),
or (b) if you meant to test skipping scaffolding for any non-empty dir, rename
the test to “does not scaffold index.mdx when content/docs is non-empty” to
reflect that behavior; adjust the test setup and assertions accordingly and keep
references to runInit and the content/docs/index.mdx path to locate the logic
under test.

Comment on lines +44 to +50
async function mirrorTree(source: string, dest: string): Promise<void> {
let entries: import('node:fs').Dirent[];
try {
const existing = await fs.readlink(linkPath);
if (existing === target) return;
await fs.unlink(linkPath);
entries = await fs.readdir(source, { withFileTypes: true });
} catch {
// link doesn't exist
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only ignore expected “not found” errors when rebuilding the mirror.

Both catches currently swallow permission/removal/read errors as if the path is missing. That can leave a stale .content tree or silently omit configured content.

Suggested fix
 async function mirrorTree(source: string, dest: string): Promise<void> {
   let entries: import('node:fs').Dirent[];
   try {
     entries = await fs.readdir(source, { withFileTypes: true });
-  } catch {
+  } catch (error) {
+    if ((error as { code?: string }).code === 'ENOENT') return;
+    throw error;
-    return;
   }
   await fs.mkdir(dest, { recursive: true });
@@
 async function removeMirror(mirrorRoot: string): Promise<void> {
   try {
     const stat = await fs.lstat(mirrorRoot);
     if (stat.isSymbolicLink() || stat.isFile()) {
       await fs.unlink(mirrorRoot);
     } else if (stat.isDirectory()) {
       await fs.rm(mirrorRoot, { recursive: true, force: true });
     }
-  } catch {
+  } catch (error) {
+    if ((error as { code?: string }).code === 'ENOENT') return;
+    throw error;
-    // mirror doesn't exist
   }
 }

Also applies to: 63-73

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/cli/utils/scaffold.ts` around lines 44 - 50, The
try/catch in mirrorTree (and the similar block around lines 63-73) is currently
swallowing all errors; change them to only suppress "not found" errors (e.g.,
check err.code === 'ENOENT' or err.code === 'ENOTDIR' as appropriate) and
rethrow or propagate any other errors (or surface them via logging) so
permission/read/delete issues are not silently ignored; update the error
handling in mirrorTree and the other catch blocks to inspect the caught error
object before deciding to return, referencing the mirrorTree function and the
subsequent directory-iteration/entry-processing try/catch blocks.

Comment on lines +16 to +18
const canonical = config.url
? `${config.url.replace(/\/$/, '')}${pathname}`
: null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make social image URLs absolute when config.url is set.

og:url is now absolute, but og:image and twitter:image still render /og?.... Some crawlers reject relative social image URLs, so derive them from the same configured base URL.

Suggested fix
   const canonical = config.url
     ? `${config.url.replace(/\/$/, '')}${pathname}`
     : null;
+  const ogImage = config.url
+    ? `${config.url.replace(/\/$/, '')}/og?${ogParams.toString()}`
+    : null;
@@
-          <meta property='og:image' content={`/og?${ogParams.toString()}`} />
+          {ogImage && <meta property='og:image' content={ogImage} />}
@@
-          <meta name='twitter:image' content={`/og?${ogParams.toString()}`} />
+          {ogImage && <meta name='twitter:image' content={ogImage} />}

Also applies to: 35-44

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/lib/head.tsx` around lines 16 - 18, The social image
URLs are still rendered as relative paths; when config.url is set you should
build absolute URLs the same way as with canonical: use
config.url.replace(/\/$/, '') as the base and prepend it to the image path (e.g.
the `/og?...` image string) before assigning to the og:image and twitter:image
meta values. Update the code that computes the image URL(s) (referencing the
existing canonical computation and the variables used to render
og:image/twitter:image in this file) to avoid double slashes and return the
absolute URL when config.url is truthy, otherwise keep the relative path.

Comment on lines +19 to +28
const description = config.site.description ?? ''

const index = pages
.map((p) => {
const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`
return `- [${p.title}](${mdUrl})`
})
.join('\n')

return `${heading}\n\n${description}\n\n${index}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty description produces a stray blank line in llms.txt.

When config.site.description is undefined, the template still emits \n\n\n\n, yielding two blank lines between the heading and the index. Minor cosmetic issue for a file LLMs consume, but easy to clean up.

Proposed fix
-  const description = config.site.description ?? ''
-
-  const index = pages
+  const index = pages
     .map((p) => {
       const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`
       return `- [${p.title}](${mdUrl})`
     })
     .join('\n')
 
-  return `${heading}\n\n${description}\n\n${index}`
+  const sections = [heading]
+  if (config.site.description) sections.push(config.site.description)
+  sections.push(index)
+  return sections.join('\n\n')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const description = config.site.description ?? ''
const index = pages
.map((p) => {
const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`
return `- [${p.title}](${mdUrl})`
})
.join('\n')
return `${heading}\n\n${description}\n\n${index}`
const index = pages
.map((p) => {
const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`
return `- [${p.title}](${mdUrl})`
})
.join('\n')
const sections = [heading]
if (config.site.description) sections.push(config.site.description)
sections.push(index)
return sections.join('\n\n')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/lib/llms.ts` around lines 19 - 28, The template always
emits extra blank lines when config.site.description is empty; update the
construction around const description, const index and the final return so the
description is only included when non-empty (e.g. build an array of sections
[heading, description, index] and filter out falsy values before joining with
'\n\n'), ensuring llms.txt does not get stray blank lines.

Comment on lines +17 to +23
const dirs =
version.dir === null
? config.content.map((c) => c.dir)
: config.versions?.find((v) => v.dir === version.dir)?.content.map(
(c) => c.dir,
) ?? []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the config helpers for version content roots.

This bypasses the same fallback/content-root logic used by the mirror and source layers. If a version relies on inherited/default content, .content.map can either crash or make the active content button disappear.

Suggested fix
-import { getLandingEntries } from './config'
+import {
+  getLandingEntries,
+  getLatestContentRoots,
+  getVersionContentRoots,
+} from './config'
@@
   const dirs =
     version.dir === null
-      ? config.content.map((c) => c.dir)
-      : config.versions?.find((v) => v.dir === version.dir)?.content.map(
-          (c) => c.dir,
-        ) ?? []
+      ? getLatestContentRoots(config).map((root) => root.contentDir)
+      : getVersionContentRoots(config, version.dir).map(
+          (root) => root.contentDir,
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/lib/navigation.ts` around lines 17 - 23, The code
computes dirs by directly accessing config.content or
config.versions?.find(...).content.map(...), which bypasses the config helper
logic and can crash when a version inherits/defaults; replace this ternary with
a call to the config helper that returns the effective content roots for a
version (e.g., use the project config function that computes version content
roots) and assign its result to dirs (falling back to []). Update the code
around dirs, version.dir, config.content and config.versions to use that helper
so inherited/default content is respected and null safety is preserved.

Comment on lines +107 to 113
function resolveCtx(tag: string | null): VersionContext {
if (!tag) return LATEST_CONTEXT;
const config = loadConfig();
const version = config.versions?.find(v => v.dir === tag);
if (!version) return LATEST_CONTEXT;
return { dir: version.dir, urlPrefix: `/${version.dir}` };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent fallback to LATEST_CONTEXT on unknown tag can mask bugs and return misleading results.

If the client passes a tag that doesn't correspond to any version in the config (e.g., a stale cached tag from a removed version, a typo, or the literal string "latest"), resolveCtx quietly returns LATEST_CONTEXT. The request succeeds with latest-version results, which is indistinguishable from a genuinely valid request and can surface cross-version content to users who think they're searching a specific version.

Consider either (a) returning a sentinel that the handler translates into an empty result / 404, or (b) at minimum logging the unknown tag so the behavior is discoverable.

♻️ One possible approach
-function resolveCtx(tag: string | null): VersionContext {
-  if (!tag) return LATEST_CONTEXT;
-  const config = loadConfig();
-  const version = config.versions?.find(v => v.dir === tag);
-  if (!version) return LATEST_CONTEXT;
-  return { dir: version.dir, urlPrefix: `/${version.dir}` };
-}
+function resolveCtx(tag: string | null): VersionContext | null {
+  if (!tag) return LATEST_CONTEXT;
+  const config = loadConfig();
+  const version = config.versions?.find(v => v.dir === tag);
+  if (!version) return null;
+  return { dir: version.dir, urlPrefix: `/${version.dir}` };
+}

and have the handler return [] (or a 404) when ctx is null.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/api/search.ts` around lines 107 - 113,
resolveCtx currently returns LATEST_CONTEXT for any unknown tag which can
silently mix versions; change resolveCtx (the function using VersionContext,
loadConfig and LATEST_CONTEXT) so that when a non-null tag is passed but no
matching version is found it returns null (or a distinct sentinel) and also
emits a warning log identifying the unknown tag; then update the caller/handler
that consumes resolveCtx to detect null and return an empty result or 404 rather
than falling back to latest.

Comment on lines +27 to +32
if (route.type === RouteType.Redirect) {
return new Response(null, {
status: route.status,
headers: { Location: route.to },
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Redirects bypass the chronicle:ssr-rendered hook.

The early return on RouteType.Redirect short-circuits before useNitroApp().hooks.callHook('chronicle:ssr-rendered', ...) at line 118, so redirect responses emit no SSR telemetry (status/duration). If the hook is consumed for request counters or latency metrics, redirects will silently disappear from them. Consider either invoking the hook for the redirect branch (with renderDuration = 0 / status = route.status) or documenting the exclusion.

📈 Proposed fix to instrument the redirect branch
     if (route.type === RouteType.Redirect) {
+      // biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook
+      useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, route.status, 0);
       return new Response(null, {
         status: route.status,
         headers: { Location: route.to },
       });
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (route.type === RouteType.Redirect) {
return new Response(null, {
status: route.status,
headers: { Location: route.to },
});
}
if (route.type === RouteType.Redirect) {
// biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook
useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, route.status, 0);
return new Response(null, {
status: route.status,
headers: { Location: route.to },
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/entry-server.tsx` around lines 27 - 32, The
early return for RouteType.Redirect in entry-server.tsx skips emitting the SSR
telemetry hook; before returning the Response for the redirect, call
useNitroApp().hooks.callHook('chronicle:ssr-rendered', { status: route.status,
renderDuration: 0, /* include same payload fields used elsewhere */ }) (await
the call if other code expects it) so redirects are recorded with renderDuration
= 0 and the redirect status, preserving the same payload shape as non-redirect
responses.

Comment on lines +14 to 22
const pages = await getPagesForVersion(LATEST_CONTEXT);
const body = buildLlmsTxt(
config,
pages.map(p => ({ url: p.url, title: extractFrontmatter(p).title })),
LATEST_CONTEXT,
);

event.res.headers.set('Content-Type', 'text/plain');
return body;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C3 'buildLlmsTxt' packages/chronicle/src/lib/llms.ts

Repository: raystack/chronicle

Length of output: 202


🏁 Script executed:

#!/bin/bash
# Get full implementation of buildLlmsTxt
rg -nP 'export function buildLlmsTxt' -A 50 packages/chronicle/src/lib/llms.ts | head -60

Repository: raystack/chronicle

Length of output: 949


🏁 Script executed:

#!/bin/bash
# Also check the LlmsPage interface definition
rg -nP 'interface LlmsPage|type LlmsPage' -A 3 packages/chronicle/src/lib/llms.ts

Repository: raystack/chronicle

Length of output: 129


Verify buildLlmsTxt handles pages without a frontmatter title.

At line 24 of packages/chronicle/src/lib/llms.ts, buildLlmsTxt uses ${p.title} directly without checking if it's undefined. The calling code at lines 19-20 passes extractFrontmatter(p).title which may be undefined for pages without a frontmatter title. This will produce - [undefined](...) entries in the generated llms.txt. Either add a fallback (e.g., page slug) or filter out pages without titles.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/routes/llms.txt.ts` around lines 14 - 22,
buildLlmsTxt is inserting page titles with `${p.title}` but callers pass
extractFrontmatter(p).title which can be undefined; update buildLlmsTxt (or the
caller mapping) to avoid `- [undefined](...)` by either skipping pages without a
title or providing a fallback (e.g., use page slug or url path). Locate the
pages mapping (where getPagesForVersion(LATEST_CONTEXT) is used and pages.map(p
=> ({ url: p.url, title: extractFrontmatter(p).title }))) and change it so title
is always a string before handing to buildLlmsTxt (e.g., title:
extractFrontmatter(p).title ?? deriveSlugFromUrl(p.url)) or modify buildLlmsTxt
to treat undefined titles the same way and omit or substitute them. Ensure
references to buildLlmsTxt and extractFrontmatter remain intact and tests/llms
output no longer contain "undefined".

Comment on lines +82 to +87
const dirNameSchema = z
.string()
.min(1)
.refine((s) => s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\'), {
message: 'dir must be a simple folder name (not ".", "..", or a path)',
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

dirNameSchema still permits reserved/hidden names and whitespace.

The refine only rejects ".", "..", and separators. It will happily accept values like ".content" (which collides with the new packageRoot/.content mirror directory created by buildContentMirror), " docs", "docs ", names with embedded newlines, NUL bytes, or OS-reserved device names (CON, PRN, …). Any of these will slip through validation and then fail later at filesystem/symlink time with a much less helpful error, or silently conflict with the mirror root.

Consider constraining to a conservative character set and disallowing leading-dot names:

🛡️ Proposed tightening
-const dirNameSchema = z
-  .string()
-  .min(1)
-  .refine((s) => s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\'), {
-    message: 'dir must be a simple folder name (not ".", "..", or a path)',
-  })
+const dirNameSchema = z
+  .string()
+  .min(1)
+  .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, {
+    message:
+      'dir must be a simple folder name: alphanumerics, ".", "_", "-", not starting with "." and without path separators',
+  })
+  .refine((s) => s !== '.' && s !== '..', {
+    message: 'dir must not be "." or ".."',
+  })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const dirNameSchema = z
.string()
.min(1)
.refine((s) => s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\'), {
message: 'dir must be a simple folder name (not ".", "..", or a path)',
})
const dirNameSchema = z
.string()
.min(1)
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, {
message:
'dir must be a simple folder name: alphanumerics, ".", "_", "-", not starting with "." and without path separators',
})
.refine((s) => s !== '.' && s !== '..', {
message: 'dir must not be "." or ".."',
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/types/config.ts` around lines 82 - 87, dirNameSchema
is too permissive: tighten it to reject leading-dot names, surrounding/embedded
whitespace, control/NUL chars, and OS-reserved device names (e.g., CON, PRN) so
mirror conflicts in buildContentMirror are avoided; update the validation on
dirNameSchema to enforce a conservative allowed charset (letters, digits,
hyphen, underscore, and optionally a single dot not at start), trim/forbid
whitespace, disallow control/NUL characters, and explicitly reject common
reserved names (CON, PRN, AUX, NUL, COM1...COM9, LPT1...LPT9) and any name
starting with '.' so invalid values are caught at schema validation time rather
than during filesystem operations.

Comment on lines +127 to +166
export const chronicleConfigSchema = z
.object({
site: siteSchema,
url: z.string().optional(),
content: z.array(contentEntrySchema).min(1),
latest: latestSchema.optional(),
versions: z.array(versionSchema).optional(),
preset: z.string().optional(),
logo: logoSchema.optional(),
theme: themeSchema.optional(),
navigation: navigationSchema.optional(),
search: searchSchema.optional(),
footer: footerSchema.optional(),
api: z.array(apiSchema).optional(),
llms: llmsSchema.optional(),
analytics: analyticsSchema.optional(),
telemetry: telemetrySchema.optional(),
})
.strict()
.refine((cfg) => allUnique(cfg.content, (c) => c.dir), {
message: 'content[].dir must be unique',
path: ['content'],
})
.refine((cfg) => !cfg.versions || allUnique(cfg.versions, (v) => v.dir), {
message: 'versions[].dir must be unique',
path: ['versions'],
})
.refine(
(cfg) =>
!cfg.versions ||
cfg.versions.every((v) => allUnique(v.content, (c) => c.dir)),
{
message: 'versions[].content[].dir must be unique within each version',
path: ['versions'],
},
)
.refine((cfg) => !cfg.versions || cfg.versions.length === 0 || !!cfg.latest, {
message: 'latest is required when versions are declared',
path: ['latest'],
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect route classifier and version resolver to confirm precedence.
fd -t f 'route-resolver.*' packages/chronicle/src
fd -t f 'source.*' packages/chronicle/src/lib
rg -nP -C4 '\b(resolveVersionFromUrl|resolveRoute|RouteType)\b' --type=ts
# Check whether any test covers "content dir equals version dir" overlap.
rg -nP -C3 '(content.*dir.*version|version.*dir.*content|shadow|overlap)' --type=ts -g '**/*.test.ts'

Repository: raystack/chronicle

Length of output: 34883


🏁 Script executed:

# Read route-resolver.ts to see exact slug handling and how versions/content interact
sed -n '40,75p' packages/chronicle/src/lib/route-resolver.ts

# Check config.test.ts for validation tests related to dirs
rg -n "dir.*must.*unique|refine|content.*version" packages/chronicle/src/lib/config.test.ts -A2 -B2

# Search for any tests that might use overlapping dirs
rg -n "dir:" packages/chronicle/src/lib/route-resolver.test.ts | head -20

Repository: raystack/chronicle

Length of output: 2028


🏁 Script executed:

# Check the exact test case that shows what slug is returned
sed -n '107,113p' packages/chronicle/src/lib/route-resolver.test.ts

# Look for the "unrecognized first segment" test which is most relevant
sed -n '115,121p' packages/chronicle/src/lib/route-resolver.test.ts

# Check the return statement on line 68 more carefully
sed -n '65,69p' packages/chronicle/src/lib/route-resolver.ts

# Search for any integration or e2e tests that might cover overlap
find packages/chronicle -name '*.test.ts' -o -name '*.spec.ts' | xargs grep -l "content.*version\|overlap" 2>/dev/null || echo "No matches found"

# Check how the slug is actually used downstream - in page lookup
rg -n "getPage\(" packages/chronicle/src -A3 -B1 | head -30

Repository: raystack/chronicle

Length of output: 2595


🏁 Script executed:

# Read the full schema refines from the config.ts file to see all validations
sed -n '127,166p' packages/chronicle/src/types/config.ts

# Check if there's any refine for cross-checking content and versions dirs
rg -n "refine\(" packages/chronicle/src/types/config.ts -A4

# Check version-source.ts to see exactly what resolveVersionFromUrl does
sed -n '11,30p' packages/chronicle/src/lib/version-source.ts

# Look for any test that tries content dir = version dir
rg -n "v1\|v2" packages/chronicle/src/lib/config.test.ts | head -20

Repository: raystack/chronicle

Length of output: 2825


🏁 Script executed:

# Check if there are any comments or docs explaining the precedence
rg -n "version.*precedence\|version.*shadow\|content.*shadow" packages/chronicle -i

# Look more carefully at the slug handling - does slug get the full parts or remainder?
sed -n '40,69p' packages/chronicle/src/lib/route-resolver.ts | cat -n

# Check if tests show what happens when a content dir matches a version dir name
rg -n "dir.*:" packages/chronicle/src/lib/route-resolver.test.ts -B3 | grep -A3 "v1\|v2" | head -40

# Verify: is there any test with content dir and version dir having same name?
cat packages/chronicle/src/lib/route-resolver.test.ts | grep -A50 "function versioned\|function singleContent\|function multiContent"

Repository: raystack/chronicle

Length of output: 4378


🏁 Script executed:

# Check the exact slug behavior - is it parts or remainder that gets passed to getPage?
sed -n '29p' packages/chronicle/src/lib/route-resolver.ts

# Look at one more test to confirm slug behavior
sed -n '99,105p' packages/chronicle/src/lib/route-resolver.test.ts

# Is there documentation or comments about the precedence?
rg -n "TODO\|FIXME\|precedence\|shadow" packages/chronicle/src/lib/route-resolver.ts packages/chronicle/src/lib/version-source.ts

# Check if 'apis' could be used as a version dir name
rg -n "apis" packages/chronicle/src/types/config.ts

Repository: raystack/chronicle

Length of output: 327


🏁 Script executed:

# Verify the critical detail: does slug use parts or remainder?
sed -n '40,75p' packages/chronicle/src/lib/route-resolver.ts | grep -A1 "type: RouteType.DocsPage"

# Check if there's any test that creates overlapping dirs
grep -r "dir:.*v1\|dir:.*v2\|dir:.*apis" packages/chronicle/src/lib/config.test.ts packages/chronicle/src/lib/route-resolver.test.ts | grep -i "content\|version" | wc -l

# Look for any explicit validation or comment about reserved names
rg -n "reserved|forbidden|cannot.*dir" packages/chronicle/src/types/config.ts

Repository: raystack/chronicle

Length of output: 125


🏁 Script executed:

# Check the actual slug behavior more carefully - test on line 108 shows slug includes 'v1'
sed -n '107,113p' packages/chronicle/src/lib/route-resolver.test.ts

# So when /v1/dev/intro is called, slug is ['v1', 'dev', 'intro'] - that includes the version!
# This suggests the bug in the code. Let's verify the API case uses remainder
sed -n '49,53p' packages/chronicle/src/lib/route-resolver.ts

# And check line 68 again - it uses parts, not remainder
sed -n '66,69p' packages/chronicle/src/lib/route-resolver.ts

# Look at how getPage handles this - if slug includes 'v1', how does it find the page?
rg -n "getPage\|filterPages" packages/chronicle/src/lib/source.ts -A5 | head -30

Repository: raystack/chronicle

Length of output: 596


🏁 Script executed:

# Critical detail: slug uses 'parts', NOT 'remainder' for DocsPage
# But API uses remainder. Let's verify the test expectation matches the code
sed -n '107,113p' packages/chronicle/src/lib/route-resolver.test.ts

# So slug for /v1/dev/intro is indeed ['v1', 'dev', 'intro'] - the FULL path, not just after version
# This means: if content[].dir = 'v1' AND versions[].dir = 'v1', the version wins but slug still includes 'v1'
# Let's check if there's any validation preventing 'apis' as a dir name
rg -n "dir.*apis\|reserved\|forbidden" packages/chronicle/src/types/config.ts packages/chronicle/src/lib/config.test.ts

# Create a test case to see if the schema accepts overlapping dirs
python3 << 'EOF'
# Show what happens if we try content dir = version dir
test_config = {
    "site": {"title": "test"},
    "content": [{"dir": "v1", "label": "Content V1"}],
    "versions": [
        {
            "dir": "v1",
            "label": "Version V1",
            "content": [{"dir": "docs", "label": "Docs"}]
        }
    ],
    "latest": {"label": "Latest"}
}
print("Test config with overlapping dir='v1' in both content and versions:")
print(test_config)
EOF

Repository: raystack/chronicle

Length of output: 607


🏁 Script executed:

# Test if the schema actually rejects overlapping content/version dirs
cd packages/chronicle && npm test -- --testNamePattern="config" 2>&1 | head -50

# Or check with a simpler approach - look at the allUnique function
rg -n "allUnique" packages/chronicle/src -B2 -A5

# Try to understand if the schema would even accept overlapping dirs
rg -n "function allUnique\|const allUnique" packages/chronicle/src

Repository: raystack/chronicle

Length of output: 608


🏁 Script executed:

# Find allUnique implementation
rg -n "allUnique" packages/chronicle/src/types/config.ts -B5 -A10

# Also search in other files
fd -t f '*.ts' packages/chronicle/src | xargs grep -l "allUnique" | head -5

# Check if it's imported from elsewhere
rg -n "import.*allUnique\|export.*allUnique" packages/chronicle/src

Repository: raystack/chronicle

Length of output: 1882


🏁 Script executed:

# Now verify the final critical detail - look at how getPage uses the slug
sed -n '150,170p' packages/chronicle/src/lib/source.ts

# And confirm that slug truly uses 'parts' and not 'remainder' for docs
sed -n '66,69p' packages/chronicle/src/lib/route-resolver.ts

Repository: raystack/chronicle

Length of output: 759


🏁 Script executed:

# Verify the actual behavior - do the tests pass as-is?
# Check what the test expects for slug on line 111
sed -n '107,113p' packages/chronicle/src/lib/route-resolver.test.ts

# Let me verify the difference between 'parts' and 'remainder'
# /v1/dev/intro splits to parts=['v1','dev','intro']
# version.dir='v1', so remainder=['dev','intro'] (after slicing)
# But line 68 returns slug: parts (NOT remainder)
# This seems like a bug, but let's check if it's intentional

# Look at how remainder is used vs parts
sed -n '40,75p' packages/chronicle/src/lib/route-resolver.ts | grep -E "remainder|parts|slug:"

Repository: raystack/chronicle

Length of output: 641


Add validation to prevent content[].dir from overlapping with versions[].dir and reserved route segments.

The schema currently validates uniqueness within content[], within versions[], and within versions[].content[], but does not prevent a top-level content directory from shadowing a version prefix or vice versa. For example, content: [{dir: "v1", …}] plus versions: [{dir: "v1", …}] will both claim the URL segment v1, and resolveVersionFromUrl will resolve the version, leaving the content dir unreachable. Similarly, a content dir named apis will be shadowed by the API route handler (line 49 in route-resolver.ts).

Either add a refine that forbids cfg.content.some(c => c.dir === cfg.versions?.[*].dir) and reserved names (apis), or document this precedence explicitly with test coverage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/types/config.ts` around lines 127 - 166,
chronicleConfigSchema currently allows top-level content.dir values to collide
with versions[].dir and reserved route segments (e.g., "apis"), causing routing
shadowing in resolveVersionFromUrl; add a .refine on chronicleConfigSchema that
(1) collects all top-level content dirs and all version dirs and fails if any
value appears in both, and (2) fails if any content.dir or version.dir is one of
the reserved route names (e.g., "apis"); provide a clear error message like
'content[].dir conflicts with versions[].dir or reserved route segments' and set
the refine path to ['content'] or ['versions'] as appropriate so the validation
rejects configs with overlapping or reserved directory names.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant