diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7cc9e45
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,33 @@
+name: ci
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: ./packages/chronicle
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.9
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+ working-directory: .
+
+ - name: Lint
+ run: bun run lint
+
+ - name: Test
+ run: bun test
diff --git a/bun.lock b/bun.lock
index b872104..15feacd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -29,8 +29,8 @@
"class-variance-authority": "^0.7.1",
"codemirror": "^6.0.2",
"commander": "^14.0.2",
- "fumadocs-core": "16.6.15",
- "fumadocs-mdx": "14.2.6",
+ "fumadocs-core": "16.8.1",
+ "fumadocs-mdx": "14.3.1",
"glob": "^11.0.0",
"gray-matter": "^4.0.3",
"h3": "^2.0.1-rc.16",
@@ -134,57 +134,57 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
- "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
- "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
- "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
- "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
- "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
- "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
- "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
- "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
- "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
- "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
- "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
- "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
- "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
- "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
- "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
- "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
- "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
- "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
- "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
- "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
- "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
- "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
- "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
- "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
- "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
- "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
@@ -194,66 +194,12 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
- "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.0", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg=="],
-
- "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "tslib": "^2.8.1" } }, "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA=="],
-
"@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="],
- "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
-
- "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
-
- "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
-
- "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
-
- "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
-
- "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
-
- "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
-
- "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
-
- "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
-
- "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
-
- "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
-
- "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
-
- "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
-
- "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
-
- "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
-
- "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
-
- "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
-
- "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
-
- "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
-
- "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
-
- "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
-
- "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
-
- "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
-
- "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
-
- "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
-
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
"@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="],
@@ -272,24 +218,6 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
- "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="],
-
- "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="],
-
- "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="],
-
- "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="],
-
- "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="],
-
- "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="],
-
- "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="],
-
- "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="],
-
- "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="],
-
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
@@ -480,8 +408,6 @@
"@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
- "@shikijs/transformers": ["@shikijs/transformers@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/types": "4.0.2" } }, "sha512-1+L0gf9v+SdDXs08vjaLb3mBFa8U7u37cwcBQIv/HCocLwX69Tt6LpUCjtB+UUTvQxI7BnjZKhN/wMjhHBcJGg=="],
-
"@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@@ -490,8 +416,6 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
- "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
-
"@tanstack/match-sorter-utils": ["@tanstack/match-sorter-utils@8.19.4", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
@@ -616,14 +540,10 @@
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
-
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
- "caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
-
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@@ -644,8 +564,6 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
- "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
-
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
@@ -798,7 +716,7 @@
"esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
- "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+ "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -838,9 +756,9 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
- "fumadocs-core": ["fumadocs-core@16.6.15", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.8.1", "@orama/orama": "^3.1.18", "@shikijs/rehype": "^4.0.2", "@shikijs/transformers": "^4.0.2", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "path-to-regexp": "^8.3.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tinyglobby": "^0.2.15", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "^0.46.0", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-N6gbXicmaylWeaEFu9vpw25dZK29rPPjalrcIqDRgDklCFkxHn0fsagDMZiSjFBn4RfWRErL6mYmu24WSwosew=="],
+ "fumadocs-core": ["fumadocs-core@16.8.1", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-NsyGZ075E7cS1RdHImuvglC8jX3v+/FRJ+Uf0r3vp+mAvc8FFnunYfq0oSLl++XdtZSsT1/27mZqBzAGL7nsDg=="],
- "fumadocs-mdx": ["fumadocs-mdx@14.2.6", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.27.2", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.1.1", "mdast-util-to-markdown": "^2.1.2", "picocolors": "^1.1.1", "picomatch": "^4.0.3", "remark-mdx": "^3.1.1", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3", "zod": "^4.3.5" }, "peerDependencies": { "@fumadocs/mdx-remote": "^1.4.0", "@types/react": "*", "fumadocs-core": "^15.0.0 || ^16.0.0", "next": "^15.3.0 || ^16.0.0", "react": "*", "vite": "6.x.x || 7.x.x" }, "optionalPeers": ["@fumadocs/mdx-remote", "@types/react", "next", "react", "vite"], "bin": { "fumadocs-mdx": "dist/bin.js" } }, "sha512-T8i5IllZ6OGaZ3/4Wwjl1zovvypSsr6Cco9ZACvoABLqpqTQ2TDfrW1nBt1o9YUKyfzkwDnjKdrnrq/nDexfcg=="],
+ "fumadocs-mdx": ["fumadocs-mdx@14.3.1", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "tinyexec": "^1.1.1", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "zod": "^4.3.6" }, "peerDependencies": { "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^15.0.0 || ^16.0.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "vite": "6.x.x || 7.x.x || 8.x.x" }, "optionalPeers": ["@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "vite"], "bin": { "fumadocs-mdx": "dist/bin.js" } }, "sha512-0u2eXvYrZtrJB14y6fDhP0hhxLgmH8JOmRv6IVHALt5MqR9JIJxV5LJYlho8g8CJXRE8w12rVNFZN0rtUVAqGw=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@@ -874,8 +792,6 @@
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
- "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="],
-
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
@@ -1072,16 +988,10 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
- "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
-
- "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="],
-
"nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="],
"nitro": ["nitro@3.0.260311-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.16", "hookable": "^6.0.1", "nf3": "^0.3.11", "ocache": "^0.1.2", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.8", "srvx": "^0.11.9", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.6" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.59.0", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ=="],
- "npm-to-yarn": ["npm-to-yarn@3.0.1", "", {}, "sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A=="],
-
"ocache": ["ocache@0.1.4", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ=="],
"ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="],
@@ -1110,8 +1020,6 @@
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
- "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
-
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -1210,8 +1118,6 @@
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
- "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
-
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -1246,15 +1152,13 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
- "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
-
"stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
- "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
+ "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
- "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="],
@@ -1334,6 +1238,8 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+ "@antfu/install-pkg/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
+
"@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1430,18 +1336,18 @@
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
- "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
-
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
- "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"vite/rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
+ "vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
@@ -1450,6 +1356,58 @@
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
+ "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
+
+ "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
+
+ "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
+
+ "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
+
+ "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
+
+ "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
+
+ "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
+
+ "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
+
+ "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
+
+ "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
+
+ "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
+
+ "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
+
+ "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
+
+ "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
+
+ "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
+
+ "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
+
+ "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
+
+ "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
+
+ "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
+
+ "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
+
+ "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
+
+ "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
+
+ "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
+
+ "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
+
+ "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
+
+ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
+
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
@@ -1483,5 +1441,7 @@
"vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
"vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
+
+ "vite/tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
}
}
diff --git a/docs/chronicle.yaml b/docs/chronicle.yaml
index 5a3de82..627ae8f 100644
--- a/docs/chronicle.yaml
+++ b/docs/chronicle.yaml
@@ -1,6 +1,10 @@
-title: Chronicle
-description: Config-driven documentation framework
-content: .
+site:
+ title: Chronicle
+ description: Config-driven documentation framework
+
+content:
+ - dir: docs
+ label: Docs
theme:
name: paper
@@ -20,6 +24,8 @@ llms:
telemetry:
enabled: true
+preset: "vercel"
+
footer:
copyright: "© 2026 Raystack. All rights reserved."
links:
diff --git a/docs/cli.mdx b/docs/content/docs/cli.mdx
similarity index 100%
rename from docs/cli.mdx
rename to docs/content/docs/cli.mdx
diff --git a/docs/components.mdx b/docs/content/docs/components.mdx
similarity index 100%
rename from docs/components.mdx
rename to docs/content/docs/components.mdx
diff --git a/docs/configuration.mdx b/docs/content/docs/configuration.mdx
similarity index 52%
rename from docs/configuration.mdx
rename to docs/content/docs/configuration.mdx
index 1c8ccb7..c5d3dc5 100644
--- a/docs/configuration.mdx
+++ b/docs/content/docs/configuration.mdx
@@ -6,15 +6,34 @@ order: 3
# Configuration
-All site configuration lives in a single `chronicle.yaml` file in your project root. The config is validated using Zod — invalid fields will produce clear error messages at startup.
+All site configuration lives in a single `chronicle.yaml` file in your project root. The config is validated using Zod — invalid fields produce clear errors at startup.
-## Full Example
+## Project layout
+
+```
+my-docs-site/
+├── chronicle.yaml
+├── content/ ← latest
+│ ├── docs/
+│ └── dev/
+└── versions/ ← only if versions: is declared
+ ├── v2/
+ │ └── docs/
+ └── v1/
+ ├── docs/
+ └── dev/
+```
+
+Content dirs declared in top-level `content:` are resolved under `content/
/` for the latest version; each `versions[].content[].dir` is resolved under `versions///`.
+
+## Full example
```yaml
-title: My Project Docs
-description: Documentation for My Project
+site:
+ title: My Project Docs
+ description: Documentation for My Project
+
url: https://docs.example.com
-content: docs
preset: vercel
logo:
@@ -24,12 +43,45 @@ logo:
theme:
name: default
+content:
+ - dir: docs
+ label: Docs
+ - dir: dev
+ label: Dev Docs
+
+latest:
+ label: "3.0"
+ landing: true
+
+versions:
+ - dir: v2
+ label: "2.0"
+ content:
+ - dir: docs
+ label: Docs
+
+ - 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
+
navigation:
links:
- label: GitHub
href: https://github.com/myorg/myproject
- - label: Blog
- href: https://blog.example.com
search:
enabled: true
@@ -40,8 +92,6 @@ footer:
links:
- label: GitHub
href: https://github.com/myorg/myproject
- - label: License
- href: /license
api:
- name: REST API
@@ -71,17 +121,24 @@ telemetry:
## Reference
-### title
+### site
-**Required.** The site title displayed in the navbar and browser tab.
+**Required.** Site-level metadata.
```yaml
-title: My Documentation
+site:
+ title: My Documentation
+ description: Documentation powered by Chronicle
```
+| Field | Type | Description |
+|-------|------|-------------|
+| `title` | `string` | Site title (navbar, browser tab, canonical metadata). Required. |
+| `description` | `string` | Meta description for SEO and OG. |
+
### url
-Optional site URL. Used for SEO metadata, sitemap, and canonical URLs.
+Optional site URL used for the sitemap and canonical URLs.
```yaml
url: https://docs.example.com
@@ -89,31 +146,83 @@ url: https://docs.example.com
### content
-Optional content directory path. Can be overridden by the `--content` CLI flag.
+**Required.** Content dirs for the latest version. Each entry maps to `content//` on disk and to `//...` in URLs.
```yaml
-content: docs
+content:
+ - dir: docs
+ label: Docs
+ - dir: dev
+ label: Dev Docs
```
-### preset
+| Field | Type | Description |
+|-------|------|-------------|
+| `dir` | `string` | Folder name under `content/`. Must be unique. |
+| `label` | `string` | Display label in navigation and landing pages. |
+
+### latest
-Optional deploy preset. Can be overridden by the `--preset` CLI flag.
+Optional metadata for the latest version. Required when `versions:` is declared.
```yaml
-preset: vercel # vercel, cloudflare, or node-server
+latest:
+ label: "3.0"
+ landing: true
```
-### description
+| Field | Type | Description | Default |
+|-------|------|-------------|---------|
+| `label` | `string` | Version label (e.g. `3.0`). Shown in the version switcher. | — |
+| `landing` | `boolean` | `true` → `/` renders a landing page listing content dirs. `false` (default) → `/` 302s to the first content dir. | `false` |
-Optional meta description for SEO.
+### versions
+
+Optional list of older versions. Each entry lives under `versions//` on disk and is reachable at `//...` in URLs.
```yaml
-description: Documentation powered by Chronicle
+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
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `dir` | `string` | Folder name under `versions/`. Doubles as URL prefix. Must be unique. |
+| `label` | `string` | Version label. Shown in the switcher. |
+| `landing` | `boolean` | `true` → `/` renders a landing page; otherwise 302s to the version's first content dir. Default `false`. |
+| `badge` | `object` | Optional Apsara badge next to the version label. |
+| `badge.label` | `string` | Badge text. |
+| `badge.variant` | `"accent" \| "warning" \| "danger" \| "success" \| "neutral" \| "gradient"` | Badge colour. Default `accent`. |
+| `content` | `{dir, label}[]` | Content dirs for this version. Entries may rename, reorder, or omit top-level content dirs. |
+| `api` | `ApiConfig[]` | Version-scoped API specs, rendered at `//apis/...`. Same shape as top-level `api:`. |
+
+### preset
+
+Optional deploy preset. Can be overridden by `--preset`.
+
+```yaml
+preset: vercel # vercel, cloudflare, or node-server
```
### logo
-Logo configuration with theme-aware variants.
+Logo with theme-aware variants.
```yaml
logo:
@@ -151,13 +260,9 @@ navigation:
links:
- label: GitHub
href: https://github.com/myorg/myproject
- - label: API
- href: /apis
social:
- type: github
href: https://github.com/myorg/myproject
- - type: discord
- href: https://discord.gg/example
```
**navigation.links**
@@ -176,7 +281,7 @@ navigation:
### search
-Search functionality powered by Fumadocs.
+Search functionality powered by Fumadocs. Automatically scoped to the active version.
```yaml
search:
@@ -189,7 +294,7 @@ search:
| `enabled` | `boolean` | Enable/disable search | `true` |
| `placeholder` | `string` | Search input placeholder | `Search...` |
-When enabled, search is accessible via the navbar button or keyboard shortcut `Cmd+K` / `Ctrl+K`.
+When enabled, search is accessible via the navbar button or keyboard shortcut `Cmd+K` / `Ctrl+K`. Active version comes from the URL; switching versions scopes the index.
### footer
@@ -212,7 +317,7 @@ footer:
### api
-OpenAPI specification configuration for interactive API documentation.
+OpenAPI specification configuration at the top level applies to the latest version (served at `/apis/...`). Version-scoped specs live under each `versions[].api`.
```yaml
api:
@@ -228,8 +333,6 @@ api:
placeholder: Enter your API key
```
-Each entry in the `api` array creates a section of API documentation.
-
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | API display name |
@@ -241,11 +344,9 @@ Each entry in the `api` array creates a section of API documentation.
| `auth.header` | `string` | Header name for auth token |
| `auth.placeholder` | `string` | Placeholder text in auth input |
-API pages include a "Try it out" panel that uses the configured server URL and auth settings.
-
### llms
-Configuration for LLM-friendly content generation. When enabled, Chronicle generates `/llms.txt` and `/llms-full.txt` endpoints.
+Per-version `llms.txt` generation.
```yaml
llms:
@@ -254,7 +355,7 @@ llms:
| Field | Type | Description | Default |
|-------|------|-------------|---------|
-| `enabled` | `boolean` | Enable/disable LLM content endpoints | `false` |
+| `enabled` | `boolean` | Emit `/llms.txt` and `//llms.txt` | `false` |
### analytics
@@ -274,7 +375,7 @@ analytics:
### telemetry
-Prometheus metrics export via OpenTelemetry. When enabled, metrics are served on a separate port.
+Prometheus metrics export via OpenTelemetry. Served on a separate port.
```yaml
telemetry:
@@ -293,10 +394,14 @@ Metrics are available at `http://localhost:/metrics` in Prometheus exposit
## Defaults
-If `chronicle.yaml` is missing or fields are omitted, these defaults apply:
+When `chronicle.yaml` is missing or fields are omitted, these defaults apply:
```yaml
-title: Documentation
+site:
+ title: Documentation
+content:
+ - dir: docs
+ label: Docs
theme:
name: default
search:
diff --git a/docs/docker.mdx b/docs/content/docs/docker.mdx
similarity index 100%
rename from docs/docker.mdx
rename to docs/content/docs/docker.mdx
diff --git a/docs/frontmatter.mdx b/docs/content/docs/frontmatter.mdx
similarity index 100%
rename from docs/frontmatter.mdx
rename to docs/content/docs/frontmatter.mdx
diff --git a/docs/index.mdx b/docs/content/docs/index.mdx
similarity index 100%
rename from docs/index.mdx
rename to docs/content/docs/index.mdx
diff --git a/docs/themes.mdx b/docs/content/docs/themes.mdx
similarity index 100%
rename from docs/themes.mdx
rename to docs/content/docs/themes.mdx
diff --git a/examples/basic/chronicle.yaml b/examples/basic/chronicle.yaml
index a14bf5d..2282d33 100644
--- a/examples/basic/chronicle.yaml
+++ b/examples/basic/chronicle.yaml
@@ -1,12 +1,20 @@
-title: My Documentation
-description: Documentation powered by Chronicle
+site:
+ title: My Documentation
+ description: Documentation powered by Chronicle
+
url: https://docs.example.com
-content: .
+
+content:
+ - dir: docs
+ label: Docs
+
theme:
name: default
+
search:
enabled: true
placeholder: Search documentation...
+
api:
- name: Petstore
spec: ./petstore.json
@@ -24,10 +32,12 @@ api:
server:
url: https://frontier.raystack.org
description: Frontier Server
+
analytics:
enabled: false
googleAnalytics:
measurementId: G-XXXXXXXXXX
+
footer:
copyright: "© 2024 Chronicle. All rights reserved."
links:
diff --git a/examples/basic/api/endpoints.mdx b/examples/basic/content/docs/api/endpoints.mdx
similarity index 100%
rename from examples/basic/api/endpoints.mdx
rename to examples/basic/content/docs/api/endpoints.mdx
diff --git a/examples/basic/api/overview.mdx b/examples/basic/content/docs/api/overview.mdx
similarity index 100%
rename from examples/basic/api/overview.mdx
rename to examples/basic/content/docs/api/overview.mdx
diff --git a/examples/basic/getting-started.mdx b/examples/basic/content/docs/getting-started.mdx
similarity index 100%
rename from examples/basic/getting-started.mdx
rename to examples/basic/content/docs/getting-started.mdx
diff --git a/examples/basic/guides/configuration.mdx b/examples/basic/content/docs/guides/configuration.mdx
similarity index 100%
rename from examples/basic/guides/configuration.mdx
rename to examples/basic/content/docs/guides/configuration.mdx
diff --git a/examples/basic/guides/installation.mdx b/examples/basic/content/docs/guides/installation.mdx
similarity index 100%
rename from examples/basic/guides/installation.mdx
rename to examples/basic/content/docs/guides/installation.mdx
diff --git a/examples/basic/index.mdx b/examples/basic/content/docs/index.mdx
similarity index 100%
rename from examples/basic/index.mdx
rename to examples/basic/content/docs/index.mdx
diff --git a/examples/versioned/chronicle.yaml b/examples/versioned/chronicle.yaml
new file mode 100644
index 0000000..818fae1
--- /dev/null
+++ b/examples/versioned/chronicle.yaml
@@ -0,0 +1,42 @@
+site:
+ title: Versioned Example
+ description: Multi-content + multi-version sample
+
+theme:
+ name: default
+
+content:
+ - dir: docs
+ label: Docs
+ - dir: dev
+ label: Dev Docs
+
+latest:
+ label: "3.0"
+ landing: true
+
+versions:
+ - dir: v2
+ label: "2.0"
+ content:
+ - dir: docs
+ label: Docs
+
+ - dir: v1
+ label: "1.0"
+ landing: true
+ badge:
+ label: deprecated
+ variant: warning
+ content:
+ - dir: dev
+ label: Developer Guide
+ - dir: docs
+ label: Docs
+
+search:
+ enabled: true
+ placeholder: Search...
+
+llms:
+ enabled: true
diff --git a/examples/versioned/content/dev/api.mdx b/examples/versioned/content/dev/api.mdx
new file mode 100644
index 0000000..f1afad0
--- /dev/null
+++ b/examples/versioned/content/dev/api.mdx
@@ -0,0 +1,8 @@
+---
+title: API notes
+order: 2
+---
+
+# Dev API notes — latest
+
+Latest `/dev/api`.
diff --git a/examples/versioned/content/dev/index.mdx b/examples/versioned/content/dev/index.mdx
new file mode 100644
index 0000000..bff0547
--- /dev/null
+++ b/examples/versioned/content/dev/index.mdx
@@ -0,0 +1,8 @@
+---
+title: Dev home (3.0)
+order: 1
+---
+
+# Dev — latest
+
+Latest `/dev`.
diff --git a/examples/versioned/content/docs/guide.mdx b/examples/versioned/content/docs/guide.mdx
new file mode 100644
index 0000000..96a4f64
--- /dev/null
+++ b/examples/versioned/content/docs/guide.mdx
@@ -0,0 +1,8 @@
+---
+title: Guide
+order: 2
+---
+
+# Guide — latest docs
+
+Latest `/docs/guide`.
diff --git a/examples/versioned/content/docs/index.mdx b/examples/versioned/content/docs/index.mdx
new file mode 100644
index 0000000..6da7902
--- /dev/null
+++ b/examples/versioned/content/docs/index.mdx
@@ -0,0 +1,9 @@
+---
+title: Docs home (3.0)
+description: Latest docs landing
+order: 1
+---
+
+# Docs — latest
+
+This is `/docs` on latest (3.0).
diff --git a/examples/versioned/versions/v1/dev/index.mdx b/examples/versioned/versions/v1/dev/index.mdx
new file mode 100644
index 0000000..e92a6fe
--- /dev/null
+++ b/examples/versioned/versions/v1/dev/index.mdx
@@ -0,0 +1,8 @@
+---
+title: Developer Guide (1.0)
+order: 1
+---
+
+# Developer Guide — v1
+
+In v1, `dev/` was relabeled "Developer Guide" and came first.
diff --git a/examples/versioned/versions/v1/docs/index.mdx b/examples/versioned/versions/v1/docs/index.mdx
new file mode 100644
index 0000000..1da0591
--- /dev/null
+++ b/examples/versioned/versions/v1/docs/index.mdx
@@ -0,0 +1,8 @@
+---
+title: Docs home (1.0)
+order: 2
+---
+
+# Docs — v1
+
+`/v1/docs` landing. Legacy version.
diff --git a/examples/versioned/versions/v2/docs/guide.mdx b/examples/versioned/versions/v2/docs/guide.mdx
new file mode 100644
index 0000000..100aced
--- /dev/null
+++ b/examples/versioned/versions/v2/docs/guide.mdx
@@ -0,0 +1,8 @@
+---
+title: Guide (v2)
+order: 2
+---
+
+# Guide — v2
+
+`/v2/docs/guide`.
diff --git a/examples/versioned/versions/v2/docs/index.mdx b/examples/versioned/versions/v2/docs/index.mdx
new file mode 100644
index 0000000..bb623bb
--- /dev/null
+++ b/examples/versioned/versions/v2/docs/index.mdx
@@ -0,0 +1,8 @@
+---
+title: Docs home (2.0)
+order: 1
+---
+
+# Docs — v2
+
+`/v2/docs` landing.
diff --git a/package.json b/package.json
index 6421a8f..5ccda8b 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,10 @@
"build:cli": "bun run --filter @raystack/chronicle build:cli",
"dev:docs": "./packages/chronicle/bin/chronicle.js dev --config docs/chronicle.yaml",
"start:docs": "./packages/chronicle/bin/chronicle.js start --config docs/chronicle.yaml",
- "build:docs": "./packages/chronicle/bin/chronicle.js build --config docs/chronicle.yaml"
+ "build:docs": "./packages/chronicle/bin/chronicle.js build --config docs/chronicle.yaml",
+ "dev:examples:basic": "./packages/chronicle/bin/chronicle.js dev --config examples/basic/chronicle.yaml",
+ "build:examples:basic": "./packages/chronicle/bin/chronicle.js build --config examples/basic/chronicle.yaml",
+ "dev:examples:versioned": "./packages/chronicle/bin/chronicle.js dev --config examples/versioned/chronicle.yaml",
+ "build:examples:versioned": "./packages/chronicle/bin/chronicle.js build --config examples/versioned/chronicle.yaml"
}
}
diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json
index 48b40e9..56afe7e 100644
--- a/packages/chronicle/package.json
+++ b/packages/chronicle/package.json
@@ -16,7 +16,8 @@
},
"scripts": {
"build:cli": "bun build-cli.ts",
- "lint": "biome lint src/"
+ "lint": "biome lint src/",
+ "test": "bun test"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
diff --git a/packages/chronicle/src/cli/commands/build.ts b/packages/chronicle/src/cli/commands/build.ts
index 02bf45e..3ac7172 100644
--- a/packages/chronicle/src/cli/commands/build.ts
+++ b/packages/chronicle/src/cli/commands/build.ts
@@ -6,33 +6,30 @@ import { linkContent } from '@/cli/utils/scaffold';
export const buildCommand = new Command('build')
.description('Build for production')
- .option('--content ', 'Content directory')
.option('--config ', 'Path to chronicle.yaml')
.option(
'--preset ',
'Deploy preset (vercel, cloudflare, node-server)'
)
.action(async options => {
- const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
- content: options.content,
+ const { config, projectRoot, configPath, preset } = await loadCLIConfig(options.config, {
preset: options.preset,
});
- await linkContent(contentDir);
+ await linkContent(projectRoot, config);
console.log(chalk.cyan('Building for production...'));
const { createBuilder } = await import('vite');
const { createViteConfig } = await import('@/server/vite-config');
- const config = await createViteConfig({
+ const viteConfig = await createViteConfig({
packageRoot: PACKAGE_ROOT,
- projectRoot: process.cwd(),
- contentDir,
+ projectRoot,
configPath,
preset
});
- const builder = await createBuilder({ ...config, builder: {} });
+ const builder = await createBuilder({ ...viteConfig, builder: {} });
await builder.buildApp();
console.log(chalk.green('Build complete'));
diff --git a/packages/chronicle/src/cli/commands/dev.ts b/packages/chronicle/src/cli/commands/dev.ts
index b34853c..80a19f9 100644
--- a/packages/chronicle/src/cli/commands/dev.ts
+++ b/packages/chronicle/src/cli/commands/dev.ts
@@ -7,24 +7,23 @@ import { linkContent } from '@/cli/utils/scaffold';
export const devCommand = new Command('dev')
.description('Start development server')
.option('-p, --port ', 'Port number', '3000')
- .option('--content ', 'Content directory')
.option('--config ', 'Path to chronicle.yaml')
.option('--host ', 'Host address', 'localhost')
.action(async options => {
- const { contentDir, configPath } = await loadCLIConfig(options.config, { content: options.content });
+ const { config, projectRoot, configPath } = await loadCLIConfig(options.config);
const port = parseInt(options.port, 10);
- await linkContent(contentDir);
+ await linkContent(projectRoot, config);
console.log(chalk.cyan('Starting dev server...'));
const { createServer } = await import('vite');
const { createViteConfig } = await import('@/server/vite-config');
- const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
+ const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath });
const server = await createServer({
- ...config,
- server: { ...config.server, port, host: options.host }
+ ...viteConfig,
+ server: { ...viteConfig.server, port, host: options.host }
});
await server.listen();
diff --git a/packages/chronicle/src/cli/commands/init.test.ts b/packages/chronicle/src/cli/commands/init.test.ts
new file mode 100644
index 0000000..4ca08e4
--- /dev/null
+++ b/packages/chronicle/src/cli/commands/init.test.ts
@@ -0,0 +1,77 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
+import { parse } from 'yaml'
+import { chronicleConfigSchema } from '@/types'
+import { runInit } from './init'
+
+let tmp: string
+
+beforeEach(() => {
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chronicle-init-'))
+})
+
+afterEach(() => {
+ fs.rmSync(tmp, { recursive: true, force: true })
+})
+
+describe('runInit', () => {
+ test('scaffolds content//, chronicle.yaml and .gitignore from empty', () => {
+ const events = runInit(tmp)
+ const created = events.filter(e => e.type === 'created').map(e => e.path)
+ expect(created).toContain(path.join(tmp, 'content/docs'))
+ expect(created).toContain(path.join(tmp, 'chronicle.yaml'))
+ expect(created).toContain(path.join(tmp, 'content/docs/index.mdx'))
+ expect(created).toContain(path.join(tmp, '.gitignore'))
+ })
+
+ test('emitted chronicle.yaml passes the schema', () => {
+ runInit(tmp)
+ const raw = fs.readFileSync(path.join(tmp, 'chronicle.yaml'), 'utf-8')
+ const parsed = chronicleConfigSchema.parse(parse(raw))
+ expect(parsed.site.title).toBe('My Documentation')
+ expect(parsed.content).toEqual([{ dir: 'docs', label: 'Docs' }])
+ })
+
+ test('skips chronicle.yaml when it already exists', () => {
+ fs.writeFileSync(path.join(tmp, 'chronicle.yaml'), 'site:\n title: Mine\n')
+ const events = runInit(tmp)
+ const yamlEvent = events.find(e => e.path.endsWith('chronicle.yaml'))
+ expect(yamlEvent?.type).toBe('skipped')
+ const raw = fs.readFileSync(path.join(tmp, 'chronicle.yaml'), 'utf-8')
+ expect(raw).toBe('site:\n title: Mine\n')
+ })
+
+ test('does not overwrite an index.mdx already present in content/docs', () => {
+ fs.mkdirSync(path.join(tmp, 'content/docs'), { recursive: true })
+ const existing = '---\ntitle: Keep\n---\n# Keep\n'
+ fs.writeFileSync(path.join(tmp, 'content/docs/index.mdx'), existing)
+ runInit(tmp)
+ const contents = fs.readFileSync(
+ path.join(tmp, 'content/docs/index.mdx'),
+ 'utf-8',
+ )
+ expect(contents).toBe(existing)
+ })
+
+ test('appends missing entries to an existing .gitignore', () => {
+ fs.writeFileSync(path.join(tmp, '.gitignore'), 'node_modules\n')
+ const events = runInit(tmp)
+ const gitignoreEvent = events.find(e => e.path.endsWith('.gitignore'))
+ expect(gitignoreEvent?.type).toBe('updated')
+ const contents = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
+ expect(contents).toContain('dist')
+ expect(contents).toContain('.output')
+ })
+
+ test('matches .gitignore entries by line, not substring', () => {
+ fs.writeFileSync(path.join(tmp, '.gitignore'), 'distribution\n')
+ runInit(tmp)
+ const contents = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
+ // `distribution` must not satisfy the `dist` requirement
+ const lines = contents.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
+ expect(lines).toContain('distribution')
+ expect(lines).toContain('dist')
+ })
+})
diff --git a/packages/chronicle/src/cli/commands/init.ts b/packages/chronicle/src/cli/commands/init.ts
index d72734d..39fe84b 100644
--- a/packages/chronicle/src/cli/commands/init.ts
+++ b/packages/chronicle/src/cli/commands/init.ts
@@ -5,9 +5,12 @@ import { Command } from 'commander';
import { stringify } from 'yaml';
import type { ChronicleConfig } from '@/types';
-const defaultConfig: ChronicleConfig = {
- title: 'My Documentation',
- description: 'Documentation powered by Chronicle',
+export const defaultInitConfig: ChronicleConfig = {
+ site: {
+ title: 'My Documentation',
+ description: 'Documentation powered by Chronicle',
+ },
+ content: [{ dir: 'docs', label: 'Docs' }],
theme: { name: 'default' },
search: { enabled: true, placeholder: 'Search documentation...' }
};
@@ -23,47 +26,77 @@ order: 1
This is your documentation home page.
`;
-export const initCommand = new Command('init')
- .description('Initialize a new Chronicle project')
- .option('-c, --content ', 'Content directory name', 'content')
- .action(options => {
- const projectDir = process.cwd();
- const contentDir = path.join(projectDir, options.content);
+const GITIGNORE_ENTRIES = ['node_modules', 'dist', '.output'];
- if (!fs.existsSync(contentDir)) {
- fs.mkdirSync(contentDir, { recursive: true });
- console.log(chalk.green('\u2713'), 'Created', contentDir);
- }
+export interface InitEvent {
+ type: 'created' | 'skipped' | 'updated';
+ path: string;
+ detail?: string;
+}
- const configPath = path.join(projectDir, 'chronicle.yaml');
- if (!fs.existsSync(configPath)) {
- fs.writeFileSync(configPath, stringify(defaultConfig));
- console.log(chalk.green('\u2713'), 'Created', configPath);
- } else {
- console.log(chalk.yellow('\u26a0'), configPath, 'already exists');
- }
+export function runInit(projectDir: string): InitEvent[] {
+ const events: InitEvent[] = [];
+ const defaultDir = defaultInitConfig.content[0].dir;
+ const contentDir = path.join(projectDir, 'content', defaultDir);
- const contentFiles = fs.readdirSync(contentDir);
- if (contentFiles.length === 0) {
- const indexPath = path.join(contentDir, 'index.mdx');
- fs.writeFileSync(indexPath, sampleMdx);
- console.log(chalk.green('\u2713'), 'Created', indexPath);
- }
+ if (!fs.existsSync(contentDir)) {
+ fs.mkdirSync(contentDir, { recursive: true });
+ events.push({ type: 'created', path: contentDir });
+ }
+
+ const configPath = path.join(projectDir, 'chronicle.yaml');
+ if (!fs.existsSync(configPath)) {
+ fs.writeFileSync(configPath, stringify(defaultInitConfig));
+ events.push({ type: 'created', path: configPath });
+ } else {
+ events.push({ type: 'skipped', path: configPath, detail: 'already exists' });
+ }
- const gitignorePath = path.join(projectDir, '.gitignore');
- const gitignoreEntries = ['node_modules', 'dist', '.output'];
- if (fs.existsSync(gitignorePath)) {
- const existing = fs.readFileSync(gitignorePath, 'utf-8');
- const missing = gitignoreEntries.filter(e => !existing.includes(e));
- if (missing.length > 0) {
- fs.appendFileSync(gitignorePath, `\n${missing.join('\n')}\n`);
- console.log(chalk.green('\u2713'), 'Added', missing.join(', '), 'to .gitignore');
- }
- } else {
- fs.writeFileSync(gitignorePath, `${gitignoreEntries.join('\n')}\n`);
- console.log(chalk.green('\u2713'), 'Created .gitignore');
+ const contentFiles = fs.readdirSync(contentDir);
+ if (contentFiles.length === 0) {
+ const indexPath = path.join(contentDir, 'index.mdx');
+ fs.writeFileSync(indexPath, sampleMdx);
+ events.push({ type: 'created', path: indexPath });
+ }
+
+ const gitignorePath = path.join(projectDir, '.gitignore');
+ if (fs.existsSync(gitignorePath)) {
+ const existing = fs.readFileSync(gitignorePath, 'utf-8');
+ const existingLines = new Set(
+ existing.split(/\r?\n/).map(l => l.trim()).filter(Boolean),
+ );
+ const missing = GITIGNORE_ENTRIES.filter(e => !existingLines.has(e));
+ if (missing.length > 0) {
+ fs.appendFileSync(gitignorePath, `\n${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 });
+ }
+
+ return events;
+}
+
+function formatEvent(e: InitEvent): string {
+ if (e.type === 'skipped') {
+ return `${chalk.yellow('⚠')} ${e.path}${e.detail ? ` ${e.detail}` : ''}`;
+ }
+ if (e.type === 'updated') {
+ return `${chalk.green('✓')} Updated ${e.path}${e.detail ? ` (+${e.detail})` : ''}`;
+ }
+ return `${chalk.green('✓')} Created ${e.path}`;
+}
- console.log(chalk.green('\n\u2713 Chronicle initialized!'));
- console.log('\nRun', chalk.cyan('chronicle dev'), 'to start development server');
+export const initCommand = new Command('init')
+ .description('Initialize a new Chronicle project')
+ .action(() => {
+ const events = runInit(process.cwd());
+ for (const e of events) console.log(formatEvent(e));
+ console.log(chalk.green('\n✓ Chronicle initialized!'));
+ console.log(
+ '\nRun',
+ chalk.cyan('chronicle dev'),
+ 'to start development server',
+ );
});
diff --git a/packages/chronicle/src/cli/commands/serve.ts b/packages/chronicle/src/cli/commands/serve.ts
index 14062c7..8a5adcf 100644
--- a/packages/chronicle/src/cli/commands/serve.ts
+++ b/packages/chronicle/src/cli/commands/serve.ts
@@ -7,7 +7,6 @@ import { linkContent } from '@/cli/utils/scaffold';
export const serveCommand = new Command('serve')
.description('Build and start production server')
.option('-p, --port ', 'Port number', '3000')
- .option('--content ', 'Content directory')
.option('--config ', 'Path to chronicle.yaml')
.option('--host ', 'Host address', 'localhost')
.option(
@@ -15,30 +14,28 @@ export const serveCommand = new Command('serve')
'Deploy preset (vercel, cloudflare, node-server)'
)
.action(async options => {
- const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
- content: options.content,
+ const { config, projectRoot, configPath, preset } = await loadCLIConfig(options.config, {
preset: options.preset,
});
const port = parseInt(options.port, 10);
- await linkContent(contentDir);
+ await linkContent(projectRoot, config);
const { build, preview } = await import('vite');
const { createViteConfig } = await import('@/server/vite-config');
- const config = await createViteConfig({
+ const viteConfig = await createViteConfig({
packageRoot: PACKAGE_ROOT,
- projectRoot: process.cwd(),
- contentDir,
+ projectRoot,
configPath,
preset
});
console.log(chalk.cyan('Building for production...'));
- await build(config);
+ await build(viteConfig);
console.log(chalk.cyan('Starting production server...'));
const server = await preview({
- ...config,
+ ...viteConfig,
preview: { port, host: options.host }
});
diff --git a/packages/chronicle/src/cli/commands/start.ts b/packages/chronicle/src/cli/commands/start.ts
index 2626b30..7be31f2 100644
--- a/packages/chronicle/src/cli/commands/start.ts
+++ b/packages/chronicle/src/cli/commands/start.ts
@@ -7,21 +7,21 @@ import { linkContent } from '@/cli/utils/scaffold';
export const startCommand = new Command('start')
.description('Start production server')
.option('-p, --port ', 'Port number', '3000')
- .option('--content ', 'Content directory')
+ .option('--config ', 'Path to chronicle.yaml')
.option('--host ', 'Host address', 'localhost')
.action(async options => {
- const { contentDir, configPath } = await loadCLIConfig(undefined, { content: options.content });
+ const { config, projectRoot, configPath } = await loadCLIConfig(options.config);
const port = parseInt(options.port, 10);
- await linkContent(contentDir);
+ await linkContent(projectRoot, config);
console.log(chalk.cyan('Starting production server...'));
const { preview } = await import('vite');
const { createViteConfig } = await import('@/server/vite-config');
- const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
+ const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath });
const server = await preview({
- ...config,
+ ...viteConfig,
preview: { port, host: options.host }
});
diff --git a/packages/chronicle/src/cli/utils/config.ts b/packages/chronicle/src/cli/utils/config.ts
index 90d7728..8056980 100644
--- a/packages/chronicle/src/cli/utils/config.ts
+++ b/packages/chronicle/src/cli/utils/config.ts
@@ -7,7 +7,7 @@ import { chronicleConfigSchema, type ChronicleConfig } from '@/types';
export interface CLIConfig {
config: ChronicleConfig;
configPath: string;
- contentDir: string;
+ projectRoot: string;
preset?: string;
}
@@ -36,8 +36,8 @@ function validateConfig(raw: string, configPath: string): ChronicleConfig {
if (!result.success) {
console.log(chalk.red(`Error: Invalid chronicle.yaml at '${configPath}'`));
for (const issue of result.error.issues) {
- const path = issue.path.join('.');
- console.log(chalk.gray(` ${path ? `${path}: ` : ''}${issue.message}`));
+ const issuePath = issue.path.join('.');
+ console.log(chalk.gray(` ${issuePath ? `${issuePath}: ` : ''}${issue.message}`));
}
process.exit(1);
}
@@ -45,27 +45,21 @@ function validateConfig(raw: string, configPath: string): ChronicleConfig {
return result.data;
}
-export function resolveContentDir(config: ChronicleConfig, configPath: string, contentFlag?: string): string {
- if (contentFlag) return path.resolve(contentFlag);
- if (config.content) return path.resolve(path.dirname(configPath), config.content);
- return path.resolve('content');
-}
-
export function resolvePreset(config: ChronicleConfig, presetFlag?: string): string | undefined {
return presetFlag ?? config.preset;
}
export async function loadCLIConfig(
configPath?: string,
- options?: { content?: string; preset?: string }
+ options?: { preset?: string }
): Promise {
const resolvedConfigPath = resolveConfigPath(configPath)
?? path.join(process.cwd(), 'chronicle.yaml');
const raw = await readConfig(resolvedConfigPath);
const config = validateConfig(raw, resolvedConfigPath);
- const contentDir = resolveContentDir(config, resolvedConfigPath, options?.content);
+ const projectRoot = path.dirname(resolvedConfigPath);
const preset = resolvePreset(config, options?.preset);
- return { config, configPath: resolvedConfigPath, contentDir, preset };
+ return { config, configPath: resolvedConfigPath, projectRoot, preset };
}
diff --git a/packages/chronicle/src/cli/utils/scaffold.test.ts b/packages/chronicle/src/cli/utils/scaffold.test.ts
new file mode 100644
index 0000000..e2c9823
--- /dev/null
+++ b/packages/chronicle/src/cli/utils/scaffold.test.ts
@@ -0,0 +1,179 @@
+import fs from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
+import { chronicleConfigSchema } from '@/types'
+import { buildContentMirror } from './scaffold'
+
+let tmp: string
+let projectRoot: string
+let mirrorRoot: string
+
+async function seedContent(relPath: string, file = 'index.mdx'): Promise {
+ const dir = path.join(projectRoot, relPath)
+ await fs.mkdir(dir, { recursive: true })
+ await fs.writeFile(path.join(dir, file), `---\ntitle: ${relPath}\n---\n`)
+}
+
+async function isDir(p: string): Promise {
+ return (await fs.lstat(p)).isDirectory()
+}
+
+async function fileSymlinkTarget(p: string): Promise {
+ const st = await fs.lstat(p)
+ expect(st.isSymbolicLink()).toBe(true)
+ return fs.readlink(p)
+}
+
+beforeEach(async () => {
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'chronicle-scaffold-'))
+ projectRoot = path.join(tmp, 'project')
+ mirrorRoot = path.join(tmp, 'mirror')
+ await fs.mkdir(projectRoot, { recursive: true })
+})
+
+afterEach(async () => {
+ await fs.rm(tmp, { recursive: true, force: true })
+})
+
+describe('buildContentMirror', () => {
+ test('single-content latest: mirrors as real dirs with per-file symlinks', async () => {
+ await seedContent('content/docs', 'index.mdx')
+ await seedContent('content/docs', 'guide.mdx')
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+
+ await buildContentMirror(mirrorRoot, projectRoot, config)
+
+ expect(await isDir(path.join(mirrorRoot, 'docs'))).toBe(true)
+ expect(await fileSymlinkTarget(path.join(mirrorRoot, 'docs/index.mdx'))).toBe(
+ path.join(projectRoot, 'content/docs/index.mdx'),
+ )
+ expect(await fileSymlinkTarget(path.join(mirrorRoot, 'docs/guide.mdx'))).toBe(
+ path.join(projectRoot, 'content/docs/guide.mdx'),
+ )
+ })
+
+ test('preserves nested subdirectories via recursive mirror', async () => {
+ await seedContent('content/docs/guides', 'install.mdx')
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+
+ await buildContentMirror(mirrorRoot, projectRoot, config)
+
+ const nested = path.join(mirrorRoot, 'docs/guides/install.mdx')
+ expect(await fileSymlinkTarget(nested)).toBe(
+ path.join(projectRoot, 'content/docs/guides/install.mdx'),
+ )
+ expect(await isDir(path.join(mirrorRoot, 'docs/guides'))).toBe(true)
+ })
+
+ test('multi-content latest produces one real dir per content entry', async () => {
+ await seedContent('content/docs', 'index.mdx')
+ await seedContent('content/dev', 'index.mdx')
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ })
+
+ await buildContentMirror(mirrorRoot, projectRoot, config)
+
+ expect(await isDir(path.join(mirrorRoot, 'docs'))).toBe(true)
+ expect(await isDir(path.join(mirrorRoot, 'dev'))).toBe(true)
+ expect(
+ await fileSymlinkTarget(path.join(mirrorRoot, 'dev/index.mdx')),
+ ).toBe(path.join(projectRoot, 'content/dev/index.mdx'))
+ })
+
+ test('versioned mirror nests version dir then content dir', async () => {
+ await seedContent('content/docs', 'index.mdx')
+ await seedContent('versions/v1/docs', 'index.mdx')
+ await seedContent('versions/v1/dev', 'api.mdx')
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ latest: { label: '2.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ },
+ ],
+ })
+
+ await buildContentMirror(mirrorRoot, projectRoot, config)
+
+ expect(await isDir(path.join(mirrorRoot, 'v1/docs'))).toBe(true)
+ expect(
+ await fileSymlinkTarget(path.join(mirrorRoot, 'v1/docs/index.mdx')),
+ ).toBe(path.join(projectRoot, 'versions/v1/docs/index.mdx'))
+ expect(
+ await fileSymlinkTarget(path.join(mirrorRoot, 'v1/dev/api.mdx')),
+ ).toBe(path.join(projectRoot, 'versions/v1/dev/api.mdx'))
+ })
+
+ test('is idempotent — re-running yields the same tree', async () => {
+ await seedContent('content/docs', 'index.mdx')
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+
+ await buildContentMirror(mirrorRoot, projectRoot, config)
+ await buildContentMirror(mirrorRoot, projectRoot, config)
+
+ expect(
+ await fileSymlinkTarget(path.join(mirrorRoot, 'docs/index.mdx')),
+ ).toBe(path.join(projectRoot, 'content/docs/index.mdx'))
+ })
+
+ test('wipes stale entries when config shrinks', async () => {
+ await seedContent('content/docs', 'index.mdx')
+ await seedContent('content/dev', 'index.mdx')
+ const before = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ })
+
+ await buildContentMirror(mirrorRoot, projectRoot, before)
+ expect((await fs.readdir(mirrorRoot)).sort()).toEqual(['dev', 'docs'])
+
+ const after = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+ await buildContentMirror(mirrorRoot, projectRoot, after)
+
+ expect(await fs.readdir(mirrorRoot)).toEqual(['docs'])
+ })
+
+ test('replaces a legacy single-symlink mirror', async () => {
+ await seedContent('content/docs', 'index.mdx')
+ await fs.symlink(path.join(projectRoot, 'content/docs'), mirrorRoot)
+
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+ await buildContentMirror(mirrorRoot, projectRoot, config)
+
+ expect(await isDir(mirrorRoot)).toBe(true)
+ expect(
+ await fileSymlinkTarget(path.join(mirrorRoot, 'docs/index.mdx')),
+ ).toBe(path.join(projectRoot, 'content/docs/index.mdx'))
+ })
+})
diff --git a/packages/chronicle/src/cli/utils/scaffold.ts b/packages/chronicle/src/cli/utils/scaffold.ts
index a27af8b..931f68c 100644
--- a/packages/chronicle/src/cli/utils/scaffold.ts
+++ b/packages/chronicle/src/cli/utils/scaffold.ts
@@ -1,18 +1,79 @@
import fs from 'node:fs/promises';
import path from 'node:path';
+import type { ChronicleConfig } from '@/types';
+import { getLatestContentRoots, getVersionContentRoots } from '@/lib/config';
import { PACKAGE_ROOT } from './resolve';
-export async function linkContent(contentDir: string): Promise {
- const linkPath = path.join(PACKAGE_ROOT, '.content');
- const target = path.resolve(contentDir);
+export async function buildContentMirror(
+ mirrorRoot: string,
+ projectRoot: string,
+ config: ChronicleConfig,
+): Promise {
+ await removeMirror(mirrorRoot);
+ await fs.mkdir(mirrorRoot, { recursive: true });
+ for (const root of getLatestContentRoots(config)) {
+ const source = path.resolve(projectRoot, root.fsPath);
+ const dest = path.join(mirrorRoot, root.contentDir);
+ await mirrorTree(source, dest);
+ }
+
+ for (const version of config.versions ?? []) {
+ const versionMirror = path.join(mirrorRoot, version.dir);
+ await fs.mkdir(versionMirror, { recursive: true });
+
+ for (const root of getVersionContentRoots(config, version.dir)) {
+ const source = path.resolve(projectRoot, root.fsPath);
+ const dest = path.join(versionMirror, root.contentDir);
+ await mirrorTree(source, dest);
+ }
+ }
+}
+
+export function linkContent(
+ projectRoot: string,
+ config: ChronicleConfig,
+): Promise {
+ return buildContentMirror(
+ path.join(PACKAGE_ROOT, '.content'),
+ projectRoot,
+ config,
+ );
+}
+
+async function mirrorTree(source: string, dest: string): Promise {
+ let entries: import('node:fs').Dirent[];
try {
- const existing = await fs.readlink(linkPath);
- if (existing === target) return;
- await fs.unlink(linkPath);
- } catch {
- // link doesn't exist
+ entries = await fs.readdir(source, { withFileTypes: true });
+ } catch (error) {
+ const err = error as NodeJS.ErrnoException;
+ if (err.code === 'ENOENT') {
+ throw new Error(`Content directory not found: ${source}`);
+ }
+ throw error;
+ }
+ await fs.mkdir(dest, { recursive: true });
+ for (const entry of entries) {
+ const sourcePath = path.join(source, entry.name);
+ const destPath = path.join(dest, entry.name);
+ if (entry.isDirectory()) {
+ await mirrorTree(sourcePath, destPath);
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
+ await fs.symlink(sourcePath, destPath);
+ }
}
+}
- await fs.symlink(target, linkPath);
+async function removeMirror(mirrorRoot: string): Promise {
+ 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 (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
+ throw error;
+ }
}
diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx
index 293893d..370c4ce 100644
--- a/packages/chronicle/src/components/ui/search.tsx
+++ b/packages/chronicle/src/components/ui/search.tsx
@@ -6,6 +6,7 @@ import { useDocsSearch } from 'fumadocs-core/search/client';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { MethodBadge } from '@/components/api/method-badge';
+import { usePageContext } from '@/lib/page-context';
import styles from './search.module.css';
function SearchShortcutKey({ className }: { className?: string }) {
@@ -30,10 +31,12 @@ interface SearchProps {
export function Search({ className }: SearchProps) {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
+ const { version } = usePageContext();
const { search, setSearch, query } = useDocsSearch({
type: 'fetch',
api: '/api/search',
+ tag: version.dir ?? undefined,
delayMs: 100,
allowEmpty: true
});
diff --git a/packages/chronicle/src/lib/config.test.ts b/packages/chronicle/src/lib/config.test.ts
new file mode 100644
index 0000000..13fdfba
--- /dev/null
+++ b/packages/chronicle/src/lib/config.test.ts
@@ -0,0 +1,493 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
+import { chronicleConfigSchema } from '@/types'
+import {
+ getAllVersions,
+ getApiConfigsForVersion,
+ getLandingEntries,
+ getLatestContentRoots,
+ getVersionContentRoots,
+ loadConfig,
+} from './config'
+
+type GlobalWithRaw = typeof globalThis & {
+ __CHRONICLE_CONFIG_RAW__?: string | null
+}
+
+const g = globalThis as GlobalWithRaw
+
+const minimal = {
+ site: { title: 'My Docs' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+}
+
+describe('chronicleConfigSchema', () => {
+ test('parses minimal single-content config', () => {
+ const parsed = chronicleConfigSchema.parse(minimal)
+ expect(parsed.site.title).toBe('My Docs')
+ expect(parsed.content).toEqual([{ dir: 'docs', label: 'Docs' }])
+ })
+
+ test('parses multi-content config', () => {
+ const parsed = chronicleConfigSchema.parse({
+ ...minimal,
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev Docs' },
+ ],
+ })
+ expect(parsed.content).toHaveLength(2)
+ })
+
+ test('parses versioned config with badge and defaults variant to accent', () => {
+ const parsed = chronicleConfigSchema.parse({
+ ...minimal,
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ badge: { label: 'deprecated' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ ],
+ })
+ expect(parsed.versions?.[0].badge).toEqual({
+ label: 'deprecated',
+ variant: 'accent',
+ })
+ })
+
+ test('accepts explicit badge variant', () => {
+ const parsed = chronicleConfigSchema.parse({
+ ...minimal,
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ badge: { label: 'deprecated', variant: 'warning' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ ],
+ })
+ expect(parsed.versions?.[0].badge?.variant).toBe('warning')
+ })
+
+ test('rejects unknown top-level field (legacy title)', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({ ...minimal, title: 'My Docs' }),
+ ).toThrow()
+ })
+
+ test('rejects string form of content', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({ site: { title: 'x' }, content: '.' }),
+ ).toThrow()
+ })
+
+ test('rejects empty content array', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({ site: { title: 'x' }, content: [] }),
+ ).toThrow()
+ })
+
+ test('rejects invalid dir names (hidden, path-shaped, whitespace)', () => {
+ const bad = ['.', '..', 'foo/bar', 'foo\\bar', '.hidden', ' docs', 'docs ', 'do cs', '']
+ for (const dir of bad) {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir, label: 'Docs' }],
+ }),
+ ).toThrow()
+ }
+ })
+
+ test('accepts standard dir names (letters, digits, dash, underscore)', () => {
+ for (const dir of ['docs', 'dev-docs', 'v1', 'v1_beta', 'api2']) {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir, label: 'x' }],
+ }),
+ ).not.toThrow()
+ }
+ })
+
+ test('rejects version dir overlapping a top-level content dir', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'v1', label: 'V1 docs' }],
+ latest: { label: '2.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ ],
+ }),
+ ).toThrow(/must not overlap/)
+ })
+
+ test('rejects reserved route segments as dir names', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'apis', label: 'API' }],
+ }),
+ ).toThrow(/reserved route segment/)
+
+ expect(() =>
+ chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ latest: { label: '2.0' },
+ versions: [
+ {
+ dir: 'apis',
+ label: '1.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ ],
+ }),
+ ).toThrow(/reserved route segment/)
+ })
+
+ test('rejects "." or ".." version dir', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ latest: { label: '2.0' },
+ versions: [
+ {
+ dir: '.',
+ label: '1.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ ],
+ }),
+ ).toThrow()
+ })
+
+ test('rejects versions without latest', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ ...minimal,
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ ],
+ }),
+ ).toThrow(/latest is required/)
+ })
+
+ test('rejects duplicate content[].dir', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ ...minimal,
+ content: [
+ { dir: 'docs', label: 'A' },
+ { dir: 'docs', label: 'B' },
+ ],
+ }),
+ ).toThrow(/content\[\]\.dir must be unique/)
+ })
+
+ test('rejects duplicate versions[].dir', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ ...minimal,
+ latest: { label: '3.0' },
+ versions: [
+ { dir: 'v1', label: '1', content: [{ dir: 'docs', label: 'd' }] },
+ { dir: 'v1', label: '1b', content: [{ dir: 'docs', label: 'd' }] },
+ ],
+ }),
+ ).toThrow(/versions\[\]\.dir must be unique/)
+ })
+
+ test('rejects duplicate content dirs within a version', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ ...minimal,
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1',
+ content: [
+ { dir: 'docs', label: 'A' },
+ { dir: 'docs', label: 'B' },
+ ],
+ },
+ ],
+ }),
+ ).toThrow(/unique within each version/)
+ })
+
+ test('rejects invalid badge variant', () => {
+ expect(() =>
+ chronicleConfigSchema.parse({
+ ...minimal,
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1',
+ badge: { label: 'x', variant: 'info' },
+ content: [{ dir: 'docs', label: 'd' }],
+ },
+ ],
+ }),
+ ).toThrow()
+ })
+})
+
+describe('getLatestContentRoots', () => {
+ test('maps each content entry to content/', () => {
+ const cfg = chronicleConfigSchema.parse({
+ ...minimal,
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev Docs' },
+ ],
+ })
+ const roots = getLatestContentRoots(cfg)
+ expect(roots).toEqual([
+ {
+ versionDir: null,
+ versionLabel: null,
+ contentDir: 'docs',
+ contentLabel: 'Docs',
+ fsPath: 'content/docs',
+ urlPrefix: '/docs',
+ },
+ {
+ versionDir: null,
+ versionLabel: null,
+ contentDir: 'dev',
+ contentLabel: 'Dev Docs',
+ fsPath: 'content/dev',
+ urlPrefix: '/dev',
+ },
+ ])
+ })
+
+ test('includes versionLabel when latest is set', () => {
+ const cfg = chronicleConfigSchema.parse({
+ ...minimal,
+ latest: { label: '3.0' },
+ })
+ expect(getLatestContentRoots(cfg)[0].versionLabel).toBe('3.0')
+ })
+})
+
+describe('getVersionContentRoots', () => {
+ test('resolves versions// and preserves config order', () => {
+ const cfg = chronicleConfigSchema.parse({
+ ...minimal,
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [
+ { dir: 'dev', label: 'Developer Guide' },
+ { dir: 'docs', label: 'Docs' },
+ ],
+ },
+ ],
+ })
+ const roots = getVersionContentRoots(cfg, 'v1')
+ expect(roots.map((r) => r.fsPath)).toEqual([
+ 'versions/v1/dev',
+ 'versions/v1/docs',
+ ])
+ expect(roots.map((r) => r.urlPrefix)).toEqual(['/v1/dev', '/v1/docs'])
+ expect(roots[0].contentLabel).toBe('Developer Guide')
+ })
+
+ test('returns empty array for unknown version', () => {
+ const cfg = chronicleConfigSchema.parse(minimal)
+ expect(getVersionContentRoots(cfg, 'v1')).toEqual([])
+ })
+})
+
+describe('getAllVersions', () => {
+ test('returns latest first then versions in config order', () => {
+ const cfg = chronicleConfigSchema.parse({
+ ...minimal,
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v2',
+ label: '2.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ {
+ dir: 'v1',
+ label: '1.0',
+ badge: { label: 'deprecated', variant: 'warning' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ ],
+ })
+ const all = getAllVersions(cfg)
+ expect(all).toEqual([
+ { dir: null, label: '3.0', isLatest: true },
+ { dir: 'v2', label: '2.0', isLatest: false },
+ {
+ dir: 'v1',
+ label: '1.0',
+ badge: { label: 'deprecated', variant: 'warning' },
+ isLatest: false,
+ },
+ ])
+ })
+
+ test('always includes the latest entry, even without latest or versions', () => {
+ const cfg = chronicleConfigSchema.parse(minimal)
+ expect(getAllVersions(cfg)).toEqual([
+ { dir: null, label: '', isLatest: true },
+ ])
+ })
+})
+
+describe('getLandingEntries', () => {
+ test('returns labels + unprefixed hrefs for latest', () => {
+ const cfg = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ })
+ expect(getLandingEntries(cfg, null)).toEqual([
+ { label: 'Docs', href: '/docs', contentDir: 'docs' },
+ { label: 'Dev', href: '/dev', contentDir: 'dev' },
+ ])
+ })
+
+ test('returns versioned hrefs for a version', () => {
+ const cfg = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [
+ { dir: 'dev', label: 'Developer Guide' },
+ { dir: 'docs', label: 'Docs' },
+ ],
+ },
+ ],
+ })
+ expect(getLandingEntries(cfg, 'v1')).toEqual([
+ { label: 'Developer Guide', href: '/v1/dev', contentDir: 'dev' },
+ { label: 'Docs', href: '/v1/docs', contentDir: 'docs' },
+ ])
+ })
+
+ test('returns empty array for unknown version', () => {
+ const cfg = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+ expect(getLandingEntries(cfg, 'v9')).toEqual([])
+ })
+})
+
+describe('getApiConfigsForVersion', () => {
+ const apiFixture = {
+ name: 'Petstore',
+ spec: './petstore.json',
+ basePath: '/apis',
+ server: { url: 'https://petstore.example.com' },
+ }
+
+ test('returns config.api for latest (null)', () => {
+ const cfg = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ api: [apiFixture],
+ })
+ expect(getApiConfigsForVersion(cfg, null)).toEqual([apiFixture])
+ })
+
+ test('returns versions[].api for a matching version', () => {
+ const versionedApi = { ...apiFixture, spec: './v1-petstore.json' }
+ const cfg = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ api: [versionedApi],
+ },
+ ],
+ })
+ expect(getApiConfigsForVersion(cfg, 'v1')).toEqual([versionedApi])
+ })
+
+ test('returns [] for unknown version or missing api', () => {
+ const cfg = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+ expect(getApiConfigsForVersion(cfg, 'v9')).toEqual([])
+ expect(getApiConfigsForVersion(cfg, null)).toEqual([])
+ })
+})
+
+describe('loadConfig', () => {
+ beforeEach(() => {
+ delete g.__CHRONICLE_CONFIG_RAW__
+ })
+
+ afterEach(() => {
+ delete g.__CHRONICLE_CONFIG_RAW__
+ })
+
+ test('returns default config when raw is undefined', () => {
+ const cfg = loadConfig()
+ expect(cfg.site.title).toBe('Documentation')
+ expect(cfg.content).toEqual([{ dir: 'docs', label: 'Docs' }])
+ })
+
+ test('returns default config when raw is null', () => {
+ g.__CHRONICLE_CONFIG_RAW__ = null
+ const cfg = loadConfig()
+ expect(cfg.site.title).toBe('Documentation')
+ })
+
+ test('parses yaml raw string', () => {
+ g.__CHRONICLE_CONFIG_RAW__ = `
+site:
+ title: Yaml Docs
+content:
+ - dir: docs
+ label: Docs
+ - dir: dev
+ label: Dev
+`
+ const cfg = loadConfig()
+ expect(cfg.site.title).toBe('Yaml Docs')
+ expect(cfg.content).toHaveLength(2)
+ })
+
+ test('throws on invalid yaml config', () => {
+ g.__CHRONICLE_CONFIG_RAW__ = 'title: Legacy'
+ expect(() => loadConfig()).toThrow()
+ })
+})
diff --git a/packages/chronicle/src/lib/config.ts b/packages/chronicle/src/lib/config.ts
index 8237c64..c6dabd3 100644
--- a/packages/chronicle/src/lib/config.ts
+++ b/packages/chronicle/src/lib/config.ts
@@ -1,32 +1,128 @@
-import { parse } from 'yaml';
-import type { ChronicleConfig } from '@/types';
+import { parse } from 'yaml'
+import {
+ type ApiConfig,
+ type BadgeConfig,
+ type ChronicleConfig,
+ chronicleConfigSchema,
+} from '@/types'
-const defaultConfig: ChronicleConfig = {
- title: 'Documentation',
+const defaultConfig: ChronicleConfig = chronicleConfigSchema.parse({
+ site: { title: 'Documentation' },
+ content: [{ dir: 'docs', label: 'Docs' }],
theme: { name: 'default' },
- search: { enabled: true, placeholder: 'Search...' }
-};
+ search: { enabled: true, placeholder: 'Search...' },
+})
export function loadConfig(): ChronicleConfig {
- const raw = typeof __CHRONICLE_CONFIG_RAW__ !== 'undefined' ? __CHRONICLE_CONFIG_RAW__ : null;
+ const raw =
+ typeof __CHRONICLE_CONFIG_RAW__ !== 'undefined'
+ ? __CHRONICLE_CONFIG_RAW__
+ : null
- if (!raw) {
- return defaultConfig;
- }
-
- const userConfig = parse(raw) as Partial;
+ if (!raw) return defaultConfig
+ const parsed = chronicleConfigSchema.parse(parse(raw))
return {
...defaultConfig,
- ...userConfig,
- theme: {
- name: userConfig.theme?.name ?? defaultConfig.theme!.name,
- colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors }
+ ...parsed,
+ theme: { ...defaultConfig.theme, ...parsed.theme },
+ search: { ...defaultConfig.search, ...parsed.search },
+ }
+}
+
+export interface ContentRoot {
+ versionDir: string | null
+ versionLabel: string | null
+ contentDir: string
+ contentLabel: string
+ fsPath: string
+ urlPrefix: string
+}
+
+export function getLatestContentRoots(config: ChronicleConfig): ContentRoot[] {
+ return config.content.map((c) => ({
+ versionDir: null,
+ versionLabel: config.latest?.label ?? null,
+ contentDir: c.dir,
+ contentLabel: c.label,
+ fsPath: `content/${c.dir}`,
+ urlPrefix: `/${c.dir}`,
+ }))
+}
+
+export function getVersionContentRoots(
+ config: ChronicleConfig,
+ versionDir: string,
+): ContentRoot[] {
+ const version = config.versions?.find((v) => v.dir === versionDir)
+ if (!version) return []
+
+ return version.content.map((c) => ({
+ versionDir: version.dir,
+ versionLabel: version.label,
+ contentDir: c.dir,
+ contentLabel: c.label,
+ fsPath: `versions/${version.dir}/${c.dir}`,
+ urlPrefix: `/${version.dir}/${c.dir}`,
+ }))
+}
+
+export interface VersionDescriptor {
+ dir: string | null
+ label: string
+ badge?: BadgeConfig
+ isLatest: boolean
+}
+
+export interface LandingEntry {
+ label: string
+ href: string
+ contentDir: string
+}
+
+export function getLandingEntries(
+ config: ChronicleConfig,
+ versionDir: string | null,
+): LandingEntry[] {
+ const roots =
+ versionDir === null
+ ? getLatestContentRoots(config)
+ : getVersionContentRoots(config, versionDir)
+
+ return roots.map((r) => ({
+ label: r.contentLabel,
+ href: r.urlPrefix,
+ contentDir: r.contentDir,
+ }))
+}
+
+export function getApiConfigsForVersion(
+ config: ChronicleConfig,
+ versionDir: string | null,
+): ApiConfig[] {
+ if (versionDir === null) return config.api ?? []
+ return (
+ config.versions?.find((v) => v.dir === versionDir)?.api ?? []
+ )
+}
+
+export function getAllVersions(config: ChronicleConfig): VersionDescriptor[] {
+ const result: VersionDescriptor[] = [
+ {
+ dir: null,
+ label: config.latest?.label ?? '',
+ isLatest: true,
},
- search: { ...defaultConfig.search, ...userConfig.search },
- footer: userConfig.footer,
- api: userConfig.api,
- llms: { enabled: false, ...userConfig.llms },
- analytics: { enabled: false, ...userConfig.analytics }
- };
+ ]
+
+ for (const v of config.versions ?? []) {
+ result.push({
+ dir: v.dir,
+ label: v.label,
+ badge: v.badge,
+ isLatest: false,
+ })
+ }
+
+ return result
}
diff --git a/packages/chronicle/src/lib/head.tsx b/packages/chronicle/src/lib/head.tsx
index 74b0621..ebdc967 100644
--- a/packages/chronicle/src/lib/head.tsx
+++ b/packages/chronicle/src/lib/head.tsx
@@ -1,3 +1,4 @@
+import { useLocation } from 'react-router';
import type { ChronicleConfig } from '@/types';
export interface HeadProps {
@@ -8,14 +9,21 @@ export interface HeadProps {
}
export function Head({ title, description, config, jsonLd }: HeadProps) {
- const fullTitle = `${title} | ${config.title}`;
+ const { pathname } = useLocation();
+ const fullTitle = `${title} | ${config.site.title}`;
const ogParams = new URLSearchParams({ title });
if (description) ogParams.set('description', description);
+ const siteUrl = config.url ? config.url.replace(/\/$/, '') : null;
+ const canonical = siteUrl ? `${siteUrl}${pathname}` : null;
+ const ogImage = siteUrl
+ ? `${siteUrl}/og?${ogParams.toString()}`
+ : `/og?${ogParams.toString()}`;
return (
<>
{fullTitle}
{description && }
+ {canonical && }
{config.url && (
<>
@@ -23,9 +31,10 @@ export function Head({ title, description, config, jsonLd }: HeadProps) {
{description && (
)}
-
+
-
+ {canonical && }
+
@@ -34,7 +43,7 @@ export function Head({ title, description, config, jsonLd }: HeadProps) {
{description && (
)}
-
+
>
)}
diff --git a/packages/chronicle/src/lib/llms.test.ts b/packages/chronicle/src/lib/llms.test.ts
new file mode 100644
index 0000000..d45ac91
--- /dev/null
+++ b/packages/chronicle/src/lib/llms.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from 'bun:test'
+import { chronicleConfigSchema } from '@/types'
+import { buildLlmsTxt } from './llms'
+import { LATEST_CONTEXT } from './version-source'
+
+describe('buildLlmsTxt', () => {
+ test('uses site.title and appends latest label when set', () => {
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'My Docs' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ ],
+ })
+
+ const out = buildLlmsTxt(
+ config,
+ [
+ { url: '/docs/a', title: 'A' },
+ { url: '/', title: 'Home' },
+ ],
+ LATEST_CONTEXT,
+ )
+
+ expect(out).toContain('# My Docs — 3.0')
+ expect(out).toContain('- [A](/docs/a.md)')
+ expect(out).toContain('- [Home](/index.md)')
+ })
+
+ test('heading has no version label when latest is absent and ctx is latest', () => {
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'My Docs' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+
+ const out = buildLlmsTxt(config, [], LATEST_CONTEXT)
+ expect(out.startsWith('# My Docs\n')).toBe(true)
+ })
+
+ test('falls back to page url when title is missing or empty', () => {
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'Docs' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+ const out = buildLlmsTxt(
+ config,
+ [
+ { url: '/docs/untitled' },
+ { url: '/docs/blank', title: ' ' },
+ ],
+ LATEST_CONTEXT,
+ )
+ expect(out).toContain('- [/docs/untitled](/docs/untitled.md)')
+ expect(out).toContain('- [/docs/blank](/docs/blank.md)')
+ })
+
+ test('omits the description line when description is empty', () => {
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'Docs' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+ const out = buildLlmsTxt(config, [{ url: '/a', title: 'A' }], LATEST_CONTEXT)
+ // heading immediately followed by a single blank line then the index
+ expect(out).toBe('# Docs\n\n- [A](/a.md)')
+ })
+
+ test('uses the version label for a versioned ctx', () => {
+ const config = chronicleConfigSchema.parse({
+ site: { title: 'My Docs' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ ],
+ })
+
+ const out = buildLlmsTxt(
+ config,
+ [{ url: '/v1/docs/a', title: 'A' }],
+ { dir: 'v1', urlPrefix: '/v1' },
+ )
+ expect(out).toContain('# My Docs — 1.0')
+ expect(out).toContain('- [A](/v1/docs/a.md)')
+ })
+})
diff --git a/packages/chronicle/src/lib/llms.ts b/packages/chronicle/src/lib/llms.ts
new file mode 100644
index 0000000..1797488
--- /dev/null
+++ b/packages/chronicle/src/lib/llms.ts
@@ -0,0 +1,41 @@
+import type { ChronicleConfig } from '@/types'
+import type { VersionContext } from './version-source'
+
+export interface LlmsPage {
+ url: string
+ title?: string
+}
+
+export function buildLlmsTxt(
+ config: ChronicleConfig,
+ pages: LlmsPage[],
+ version: VersionContext,
+): string {
+ const versionLabel = getVersionLabel(config, version)
+ const heading = versionLabel
+ ? `# ${config.site.title} — ${versionLabel}`
+ : `# ${config.site.title}`
+
+ const description = config.site.description ?? ''
+
+ const index = pages
+ .map((p) => {
+ const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`
+ const title = p.title?.trim() || p.url
+ return `- [${title}](${mdUrl})`
+ })
+ .join('\n')
+
+ const parts = [heading]
+ if (description) parts.push(description)
+ parts.push(index)
+ return parts.join('\n\n')
+}
+
+function getVersionLabel(
+ config: ChronicleConfig,
+ version: VersionContext,
+): string | null {
+ if (version.dir === null) return config.latest?.label ?? null
+ return config.versions?.find((v) => v.dir === version.dir)?.label ?? null
+}
diff --git a/packages/chronicle/src/lib/navigation.test.ts b/packages/chronicle/src/lib/navigation.test.ts
new file mode 100644
index 0000000..0042ed2
--- /dev/null
+++ b/packages/chronicle/src/lib/navigation.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from 'bun:test'
+import { type ChronicleConfig, chronicleConfigSchema } from '@/types'
+import {
+ getActiveContentDir,
+ getVersionHomeHref,
+ splitContentButtons,
+} from './navigation'
+
+function versioned(): ChronicleConfig {
+ return chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ },
+ ],
+ })
+}
+
+describe('getActiveContentDir', () => {
+ test('returns latest content dir from URL', () => {
+ expect(getActiveContentDir('/docs/intro', versioned())).toBe('docs')
+ expect(getActiveContentDir('/dev/setup', versioned())).toBe('dev')
+ })
+
+ test('returns versioned content dir from URL', () => {
+ expect(getActiveContentDir('/v1/docs/intro', versioned())).toBe('docs')
+ expect(getActiveContentDir('/v1/dev/setup', versioned())).toBe('dev')
+ })
+
+ test('returns null for root and api routes', () => {
+ expect(getActiveContentDir('/', versioned())).toBeNull()
+ expect(getActiveContentDir('/v1', versioned())).toBeNull()
+ expect(getActiveContentDir('/apis/x', versioned())).toBeNull()
+ expect(getActiveContentDir('/v1/apis/x', versioned())).toBeNull()
+ })
+
+ test('returns null for unknown content dir', () => {
+ expect(getActiveContentDir('/random', versioned())).toBeNull()
+ expect(getActiveContentDir('/v1/random', versioned())).toBeNull()
+ })
+})
+
+describe('getVersionHomeHref', () => {
+ test('single content dir returns /', () => {
+ const cfg = chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+ expect(getVersionHomeHref(cfg, null)).toBe('/docs')
+ })
+
+ test('multi content dir returns / (version root for landing)', () => {
+ expect(getVersionHomeHref(versioned(), null)).toBe('/')
+ })
+
+ test('versioned multi content returns /', () => {
+ expect(getVersionHomeHref(versioned(), 'v1')).toBe('/v1')
+ })
+
+ test('unknown version returns / fallback', () => {
+ expect(getVersionHomeHref(versioned(), 'v9')).toBe('/v9')
+ })
+})
+
+describe('splitContentButtons', () => {
+ test('all visible when length <= max', () => {
+ expect(splitContentButtons([1, 2, 3], 3)).toEqual({
+ visible: [1, 2, 3],
+ overflow: [],
+ })
+ })
+
+ test('first max visible, rest overflow', () => {
+ expect(splitContentButtons([1, 2, 3, 4, 5], 3)).toEqual({
+ visible: [1, 2, 3],
+ overflow: [4, 5],
+ })
+ })
+
+ test('empty input returns empty arrays', () => {
+ expect(splitContentButtons([], 3)).toEqual({ visible: [], overflow: [] })
+ })
+})
diff --git a/packages/chronicle/src/lib/navigation.ts b/packages/chronicle/src/lib/navigation.ts
new file mode 100644
index 0000000..b889432
--- /dev/null
+++ b/packages/chronicle/src/lib/navigation.ts
@@ -0,0 +1,51 @@
+import type { ChronicleConfig } from '@/types'
+import {
+ getLandingEntries,
+ getLatestContentRoots,
+ getVersionContentRoots,
+} from './config'
+import { resolveVersionFromUrl } from './version-source'
+
+export function getActiveContentDir(
+ url: string,
+ config: ChronicleConfig,
+): string | null {
+ const version = resolveVersionFromUrl(url, config)
+ const parts = url.split('/').filter(Boolean)
+ const remainder =
+ version.dir !== null && parts[0] === version.dir ? parts.slice(1) : parts
+
+ if (remainder.length === 0) return null
+ if (remainder[0] === 'apis') return null
+
+ const dirs =
+ version.dir === null
+ ? getLatestContentRoots(config).map((root) => root.contentDir)
+ : getVersionContentRoots(config, version.dir).map(
+ (root) => root.contentDir,
+ )
+
+ return dirs.includes(remainder[0]) ? remainder[0] : null
+}
+
+export function getVersionHomeHref(
+ config: ChronicleConfig,
+ versionDir: string | null,
+): string {
+ const entries = getLandingEntries(config, versionDir)
+ if (entries.length === 1) return entries[0].href
+ return versionDir ? `/${versionDir}` : '/'
+}
+
+export interface ContentButtonSplit {
+ visible: T[]
+ overflow: T[]
+}
+
+export function splitContentButtons(
+ items: T[],
+ max: number,
+): ContentButtonSplit {
+ if (items.length <= max) return { visible: items, overflow: [] }
+ return { visible: items.slice(0, max), overflow: items.slice(max) }
+}
diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx
index a421459..9487a9d 100644
--- a/packages/chronicle/src/lib/page-context.tsx
+++ b/packages/chronicle/src/lib/page-context.tsx
@@ -7,6 +7,9 @@ import {
} from 'react';
import { useLocation } from 'react-router';
import type { ApiSpec } from '@/lib/openapi';
+import { resolveRoute, RouteType } from '@/lib/route-resolver';
+import type { VersionContext } from '@/lib/version-source';
+import { LATEST_CONTEXT } from '@/lib/version-source';
import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
@@ -24,6 +27,7 @@ interface PageContextValue {
page: PageData | null;
errorStatus: number | null;
apiSpecs: ApiSpec[];
+ version: VersionContext;
}
const PageContext = createContext(null);
@@ -33,11 +37,15 @@ export function usePageContext(): PageContextValue {
if (!ctx) {
console.error('usePageContext: no context found!');
return {
- config: { title: 'Documentation' },
+ config: {
+ site: { title: 'Documentation' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
tree: { name: 'root', children: [] } as Root,
page: null,
errorStatus: null,
- apiSpecs: []
+ apiSpecs: [],
+ version: LATEST_CONTEXT,
};
}
return ctx;
@@ -48,17 +56,21 @@ interface PageProviderProps {
initialTree: Root;
initialPage: PageData | null;
initialApiSpecs: ApiSpec[];
+ initialVersion: VersionContext;
loadMdx: MdxLoader;
children: ReactNode;
}
-function isApisRoute(pathname: string): boolean {
- return pathname === '/apis' || pathname.startsWith('/apis/');
-}
-
-function getInitialErrorStatus(page: PageData | null, pathname: string): number | null {
+function getInitialErrorStatus(
+ page: PageData | null,
+ config: ChronicleConfig,
+ pathname: string,
+): number | null {
if (page) return null;
- if (pathname === '/' || isApisRoute(pathname)) return null;
+ const route = resolveRoute(pathname, config);
+ if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) return null;
+ if (route.type === RouteType.Redirect) return null;
+ if (route.type === RouteType.DocsIndex) return null;
return 404;
}
@@ -67,39 +79,55 @@ export function PageProvider({
initialTree,
initialPage,
initialApiSpecs,
+ initialVersion,
loadMdx,
children
}: PageProviderProps) {
const { pathname } = useLocation();
const [tree] = useState(initialTree);
const [page, setPage] = useState(initialPage);
- const [errorStatus, setErrorStatus] = useState(getInitialErrorStatus(initialPage, pathname));
+ const [errorStatus, setErrorStatus] = useState(
+ getInitialErrorStatus(initialPage, initialConfig, pathname),
+ );
const [apiSpecs, setApiSpecs] = useState(initialApiSpecs);
+ const [version, setVersion] = useState(initialVersion);
const [currentPath, setCurrentPath] = useState(pathname);
useEffect(() => {
if (pathname === currentPath) return;
setCurrentPath(pathname);
+ const route = resolveRoute(pathname, initialConfig);
+ if (route.type !== RouteType.Redirect) setVersion(route.version);
+
const cancelled = { current: false };
- if (isApisRoute(pathname)) {
- if (apiSpecs.length === 0) {
- fetch('/api/specs')
- .then(res => res.json())
- .then(specs => {
- if (!cancelled.current) setApiSpecs(specs);
- })
- .catch(() => {});
- }
+ if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) {
+ setPage(null);
+ setErrorStatus(null);
+ const specsUrl = route.version.dir
+ ? `/api/specs?version=${encodeURIComponent(route.version.dir)}`
+ : '/api/specs';
+ fetch(specsUrl)
+ .then(res => res.json())
+ .then(specs => {
+ if (!cancelled.current) setApiSpecs(specs);
+ })
+ .catch(() => {
+ // swallow — api specs are best-effort on client nav
+ });
return () => { cancelled.current = true; };
}
- const slug = pathname === '/'
- ? []
- : pathname.slice(1).split('/').filter(Boolean);
+ if (route.type !== RouteType.DocsPage) {
+ setPage(null);
+ setErrorStatus(null);
+ return () => { cancelled.current = true; };
+ }
- const apiPath = slug.length === 0 ? '/api/page' : `/api/page?slug=${slug.join(',')}`;
+ const apiPath = route.slug.length === 0
+ ? '/api/page'
+ : `/api/page?slug=${route.slug.join(',')}`;
fetch(apiPath)
.then(res => {
@@ -117,7 +145,7 @@ export function PageProvider({
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
if (cancelled.current) return;
setErrorStatus(null);
- setPage({ slug, frontmatter: data.frontmatter, content, toc });
+ setPage({ slug: route.slug, frontmatter: data.frontmatter, content, toc });
})
.catch(() => {
if (!cancelled.current) {
@@ -131,7 +159,7 @@ export function PageProvider({
return (
{children}
diff --git a/packages/chronicle/src/lib/route-resolver.test.ts b/packages/chronicle/src/lib/route-resolver.test.ts
new file mode 100644
index 0000000..ccc2ba3
--- /dev/null
+++ b/packages/chronicle/src/lib/route-resolver.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, test } from 'bun:test'
+import { type ChronicleConfig, chronicleConfigSchema } from '@/types'
+import { resolveRoute, RouteType } from './route-resolver'
+import { LATEST_CONTEXT } from './version-source'
+
+function singleContent(): ChronicleConfig {
+ return chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ })
+}
+
+function multiContent(): ChronicleConfig {
+ return chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ latest: { label: '3.0', landing: true },
+ })
+}
+
+function multiContentNoLanding(): ChronicleConfig {
+ return chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ })
+}
+
+function versioned(): ChronicleConfig {
+ return chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v2',
+ label: '2.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ {
+ dir: 'v1',
+ label: '1.0',
+ landing: true,
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ },
+ ],
+ })
+}
+
+describe('resolveRoute — root', () => {
+ test('redirects single-content latest root to /', () => {
+ expect(resolveRoute('/', singleContent())).toEqual({
+ type: RouteType.Redirect,
+ to: '/docs',
+ status: 302,
+ })
+ })
+
+ test('docs-index for latest root when latest.landing = true', () => {
+ expect(resolveRoute('/', multiContent())).toEqual({
+ type: RouteType.DocsIndex,
+ version: LATEST_CONTEXT,
+ })
+ })
+
+ test('redirect for multi-content latest root when landing is not set', () => {
+ expect(resolveRoute('/', multiContentNoLanding())).toEqual({
+ type: RouteType.Redirect,
+ to: '/docs',
+ status: 302,
+ })
+ })
+
+ test('redirects single-content version root to //', () => {
+ expect(resolveRoute('/v2', versioned())).toEqual({
+ type: RouteType.Redirect,
+ to: '/v2/docs',
+ status: 302,
+ })
+ })
+
+ test('docs-index for version root when versions[].landing = true', () => {
+ expect(resolveRoute('/v1', versioned())).toEqual({
+ type: RouteType.DocsIndex,
+ version: { dir: 'v1', urlPrefix: '/v1' },
+ })
+ })
+})
+
+describe('resolveRoute — docs pages', () => {
+ test('latest docs page returns full slug and latest context', () => {
+ expect(resolveRoute('/docs/getting-started', singleContent())).toEqual({
+ type: RouteType.DocsPage,
+ version: LATEST_CONTEXT,
+ slug: ['docs', 'getting-started'],
+ })
+ })
+
+ test('versioned docs page returns full slug and version context', () => {
+ expect(resolveRoute('/v1/dev/intro', versioned())).toEqual({
+ type: RouteType.DocsPage,
+ version: { dir: 'v1', urlPrefix: '/v1' },
+ slug: ['v1', 'dev', 'intro'],
+ })
+ })
+
+ test('unrecognized first segment stays latest (page lookup handles 404)', () => {
+ expect(resolveRoute('/foo/bar', singleContent())).toEqual({
+ type: RouteType.DocsPage,
+ version: LATEST_CONTEXT,
+ slug: ['foo', 'bar'],
+ })
+ })
+})
+
+describe('resolveRoute — APIs', () => {
+ test('latest api index', () => {
+ expect(resolveRoute('/apis', singleContent())).toEqual({
+ type: RouteType.ApiIndex,
+ version: LATEST_CONTEXT,
+ })
+ })
+
+ test('latest api page', () => {
+ expect(resolveRoute('/apis/petstore/getPetById', singleContent())).toEqual({
+ type: RouteType.ApiPage,
+ version: LATEST_CONTEXT,
+ slug: ['petstore', 'getPetById'],
+ })
+ })
+
+ test('versioned api index', () => {
+ expect(resolveRoute('/v1/apis', versioned())).toEqual({
+ type: RouteType.ApiIndex,
+ version: { dir: 'v1', urlPrefix: '/v1' },
+ })
+ })
+
+ test('versioned api page', () => {
+ expect(
+ resolveRoute('/v1/apis/petstore/getPetById', versioned()),
+ ).toEqual({
+ type: RouteType.ApiPage,
+ version: { dir: 'v1', urlPrefix: '/v1' },
+ slug: ['petstore', 'getPetById'],
+ })
+ })
+})
+
+describe('resolveRoute — edge cases', () => {
+ test('trailing slash is normalized', () => {
+ expect(resolveRoute('/v1/', versioned())).toEqual({
+ type: RouteType.DocsIndex,
+ version: { dir: 'v1', urlPrefix: '/v1' },
+ })
+ })
+
+ test('version-shaped path without a matching version stays latest', () => {
+ expect(resolveRoute('/v9/docs', versioned())).toEqual({
+ type: RouteType.DocsPage,
+ version: LATEST_CONTEXT,
+ slug: ['v9', 'docs'],
+ })
+ })
+})
diff --git a/packages/chronicle/src/lib/route-resolver.ts b/packages/chronicle/src/lib/route-resolver.ts
new file mode 100644
index 0000000..59fa99c
--- /dev/null
+++ b/packages/chronicle/src/lib/route-resolver.ts
@@ -0,0 +1,73 @@
+import type { ChronicleConfig } from '@/types'
+import { getLatestContentRoots, getVersionContentRoots } from './config'
+import { type VersionContext, resolveVersionFromUrl } from './version-source'
+
+export const RouteType = {
+ Redirect: 'redirect',
+ DocsIndex: 'docs-index',
+ DocsPage: 'docs-page',
+ ApiIndex: 'api-index',
+ ApiPage: 'api-page',
+} as const
+
+export type RouteType = (typeof RouteType)[keyof typeof RouteType]
+
+export type Route =
+ | { type: typeof RouteType.Redirect; to: string; status: 302 }
+ | { type: typeof RouteType.DocsIndex; version: VersionContext }
+ | { type: typeof RouteType.DocsPage; version: VersionContext; slug: string[] }
+ | { type: typeof RouteType.ApiIndex; version: VersionContext }
+ | { type: typeof RouteType.ApiPage; version: VersionContext; slug: string[] }
+
+function contentDirsFor(
+ config: ChronicleConfig,
+ version: VersionContext,
+): string[] {
+ if (version.dir === null) {
+ return getLatestContentRoots(config).map((root) => root.contentDir)
+ }
+ return getVersionContentRoots(config, version.dir).map(
+ (root) => root.contentDir,
+ )
+}
+
+function isLandingEnabled(
+ config: ChronicleConfig,
+ version: VersionContext,
+): boolean {
+ if (version.dir === null) return config.latest?.landing === true
+ return (
+ config.versions?.find((v) => v.dir === version.dir)?.landing === true
+ )
+}
+
+export function resolveRoute(
+ pathname: string,
+ config: ChronicleConfig,
+): Route {
+ const parts = pathname.split('/').filter(Boolean)
+ const version = resolveVersionFromUrl(pathname, config)
+ const remainder =
+ version.dir !== null && parts[0] === version.dir ? parts.slice(1) : parts
+
+ if (remainder[0] === 'apis') {
+ const slug = remainder.slice(1)
+ if (slug.length === 0) return { type: RouteType.ApiIndex, version }
+ return { type: RouteType.ApiPage, version, slug }
+ }
+
+ if (remainder.length === 0) {
+ if (isLandingEnabled(config, version)) {
+ return { type: RouteType.DocsIndex, version }
+ }
+ const dirs = contentDirsFor(config, version)
+ if (dirs.length === 0) return { type: RouteType.DocsIndex, version }
+ return {
+ type: RouteType.Redirect,
+ to: `${version.urlPrefix}/${dirs[0]}`,
+ status: 302,
+ }
+ }
+
+ return { type: RouteType.DocsPage, version, slug: parts }
+}
diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts
index 0d9e155..e528c8d 100644
--- a/packages/chronicle/src/lib/source.ts
+++ b/packages/chronicle/src/lib/source.ts
@@ -3,6 +3,17 @@ import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
import type { MDXContent } from 'mdx/types';
import type { TableOfContents } from 'fumadocs-core/toc';
import type { Frontmatter } from '@/types';
+import {
+ getLatestContentRoots,
+ getVersionContentRoots,
+ loadConfig,
+} from './config';
+import {
+ filterPagesByVersion,
+ filterPageTreeByVersion,
+ resolveVersionFromUrl,
+ type VersionContext,
+} from './version-source';
const CONTENT_PREFIX = '../../.content/';
@@ -33,14 +44,50 @@ function buildFiles() {
});
}
+ const userMetaPaths = new Set();
for (const [key, data] of Object.entries(metaGlob)) {
const relativePath = key.slice(CONTENT_PREFIX.length);
+ userMetaPaths.add(relativePath);
files.push({ type: 'meta', path: relativePath, data: data ?? {} });
}
+ for (const entry of buildSyntheticMeta()) {
+ if (userMetaPaths.has(entry.path)) continue;
+ files.push(entry);
+ }
+
return files;
}
+function buildSyntheticMeta(): {
+ type: 'meta';
+ path: string;
+ data: Record;
+}[] {
+ const config = loadConfig();
+ const entries: { type: 'meta'; path: string; data: Record }[] = [];
+
+ for (const root of getLatestContentRoots(config)) {
+ entries.push({
+ type: 'meta',
+ path: `${root.contentDir}/meta.json`,
+ data: { title: root.contentLabel, root: true },
+ });
+ }
+
+ for (const version of config.versions ?? []) {
+ for (const root of getVersionContentRoots(config, version.dir)) {
+ entries.push({
+ type: 'meta',
+ path: `${version.dir}/${root.contentDir}/meta.json`,
+ data: { title: root.contentLabel, root: true },
+ });
+ }
+ }
+
+ return entries;
+}
+
let cachedSource: ReturnType | null = null;
async function getSource() {
@@ -105,6 +152,22 @@ export async function getPage(slugs?: string[]) {
return s.getPage(slugs);
}
+export async function getPageTreeForVersion(ctx: VersionContext): Promise {
+ const tree = await getPageTree();
+ return filterPageTreeByVersion(tree, ctx, loadConfig());
+}
+
+export async function getPagesForVersion(ctx: VersionContext) {
+ const pages = await getPages();
+ return filterPagesByVersion(pages, ctx, loadConfig());
+}
+
+export function getVersionContextForUrl(url: string): VersionContext {
+ return resolveVersionFromUrl(url, loadConfig());
+}
+
+export type { VersionContext } from './version-source';
+
export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter {
const d = page.data as Record;
return {
diff --git a/packages/chronicle/src/lib/version-source.test.ts b/packages/chronicle/src/lib/version-source.test.ts
new file mode 100644
index 0000000..2953c10
--- /dev/null
+++ b/packages/chronicle/src/lib/version-source.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, test } from 'bun:test'
+import type { Folder, Item, Root } from 'fumadocs-core/page-tree'
+import { type ChronicleConfig, chronicleConfigSchema } from '@/types'
+import {
+ filterPagesByVersion,
+ filterPageTreeByContentDir,
+ filterPageTreeByVersion,
+ LATEST_CONTEXT,
+ resolveVersionFromUrl,
+} from './version-source'
+
+function makeConfig(): ChronicleConfig {
+ return chronicleConfigSchema.parse({
+ site: { title: 'x' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+ latest: { label: '3.0' },
+ versions: [
+ {
+ dir: 'v2',
+ label: '2.0',
+ content: [{ dir: 'docs', label: 'Docs' }],
+ },
+ {
+ dir: 'v1',
+ label: '1.0',
+ content: [
+ { dir: 'docs', label: 'Docs' },
+ { dir: 'dev', label: 'Dev' },
+ ],
+ },
+ ],
+ })
+}
+
+function page(url: string): Item {
+ return { type: 'page', name: url, url }
+}
+
+function folder(name: string, children: (Item | Folder)[]): Folder {
+ return { type: 'folder', name, children }
+}
+
+describe('resolveVersionFromUrl', () => {
+ const config = makeConfig()
+
+ test('returns latest context for unprefixed URLs', () => {
+ expect(resolveVersionFromUrl('/docs/getting-started', config)).toEqual(
+ LATEST_CONTEXT,
+ )
+ expect(resolveVersionFromUrl('/', config)).toEqual(LATEST_CONTEXT)
+ })
+
+ test('returns version context when URL matches a version prefix', () => {
+ expect(resolveVersionFromUrl('/v1/docs/intro', config)).toEqual({
+ dir: 'v1',
+ urlPrefix: '/v1',
+ })
+ expect(resolveVersionFromUrl('/v2', config)).toEqual({
+ dir: 'v2',
+ urlPrefix: '/v2',
+ })
+ })
+
+ test('does not match a version when prefix is only a substring', () => {
+ expect(resolveVersionFromUrl('/v1beta/docs', config)).toEqual(LATEST_CONTEXT)
+ })
+})
+
+describe('filterPagesByVersion', () => {
+ const config = makeConfig()
+ const pages = [
+ { url: '/docs/a' },
+ { url: '/docs/b' },
+ { url: '/v1/docs/a' },
+ { url: '/v1/dev/b' },
+ { url: '/v2/docs/a' },
+ ]
+
+ test('latest excludes all versioned pages', () => {
+ expect(filterPagesByVersion(pages, LATEST_CONTEXT, config)).toEqual([
+ { url: '/docs/a' },
+ { url: '/docs/b' },
+ ])
+ })
+
+ test('version returns only pages under its prefix', () => {
+ expect(
+ filterPagesByVersion(pages, { dir: 'v1', urlPrefix: '/v1' }, config),
+ ).toEqual([{ url: '/v1/docs/a' }, { url: '/v1/dev/b' }])
+ })
+})
+
+describe('filterPageTreeByVersion', () => {
+ const config = makeConfig()
+ const latestDocs = folder('docs', [page('/docs/a'), page('/docs/b')])
+ const v1Folder = folder('v1', [
+ folder('docs', [page('/v1/docs/a')]),
+ folder('dev', [page('/v1/dev/a')]),
+ ])
+ const v2Folder = folder('v2', [folder('docs', [page('/v2/docs/a')])])
+
+ const tree: Root = {
+ name: 'root',
+ children: [latestDocs, v1Folder, v2Folder],
+ }
+
+ test('latest drops version folders', () => {
+ const filtered = filterPageTreeByVersion(tree, LATEST_CONTEXT, config)
+ expect(filtered.children).toEqual([latestDocs])
+ })
+
+ test('version returns the inner children of its folder', () => {
+ const filtered = filterPageTreeByVersion(
+ tree,
+ { dir: 'v1', urlPrefix: '/v1' },
+ config,
+ )
+ expect(filtered.children).toEqual(v1Folder.children)
+ })
+
+ test('version returns empty children when the version folder is absent', () => {
+ const filtered = filterPageTreeByVersion(
+ { name: 'root', children: [latestDocs] },
+ { dir: 'v1', urlPrefix: '/v1' },
+ config,
+ )
+ expect(filtered.children).toEqual([])
+ })
+})
+
+describe('filterPageTreeByContentDir', () => {
+ const latestDocs = folder('docs', [page('/docs/a'), page('/docs/b')])
+ const latestDev = folder('dev', [page('/dev/x')])
+ const latestTree: Root = {
+ name: 'root',
+ children: [latestDocs, latestDev],
+ }
+
+ test('null contentDir returns tree unchanged', () => {
+ const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, null)
+ expect(out).toBe(latestTree)
+ })
+
+ test('returns just the matching content folder children (latest)', () => {
+ const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, 'docs')
+ expect(out.children).toEqual(latestDocs.children)
+ })
+
+ test('returns empty children when content dir is absent', () => {
+ const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, 'missing')
+ expect(out.children).toEqual([])
+ })
+
+ test('uses version urlPrefix to disambiguate within a version', () => {
+ const v1Docs = folder('docs', [page('/v1/docs/a')])
+ const v1Dev = folder('dev', [page('/v1/dev/x')])
+ const ctx = { dir: 'v1', urlPrefix: '/v1' }
+ const tree: Root = { name: 'root', children: [v1Docs, v1Dev] }
+ expect(
+ filterPageTreeByContentDir(tree, ctx, 'dev').children,
+ ).toEqual(v1Dev.children)
+ })
+})
diff --git a/packages/chronicle/src/lib/version-source.ts b/packages/chronicle/src/lib/version-source.ts
new file mode 100644
index 0000000..1de0685
--- /dev/null
+++ b/packages/chronicle/src/lib/version-source.ts
@@ -0,0 +1,101 @@
+import type { Folder, Node, Root } from 'fumadocs-core/page-tree'
+import type { ChronicleConfig } from '@/types'
+
+export interface VersionContext {
+ dir: string | null
+ urlPrefix: string
+}
+
+export const LATEST_CONTEXT: VersionContext = { dir: null, urlPrefix: '' }
+
+export function resolveVersionFromUrl(
+ url: string,
+ config: ChronicleConfig,
+): VersionContext {
+ for (const v of config.versions ?? []) {
+ const prefix = `/${v.dir}`
+ if (url === prefix || url.startsWith(`${prefix}/`)) {
+ return { dir: v.dir, urlPrefix: prefix }
+ }
+ }
+ return LATEST_CONTEXT
+}
+
+function versionPrefixes(config: ChronicleConfig): string[] {
+ return (config.versions ?? []).map((v) => `/${v.dir}`)
+}
+
+function isUnderPrefix(url: string, prefix: string): boolean {
+ return url === prefix || url.startsWith(`${prefix}/`)
+}
+
+export function filterPagesByVersion(
+ pages: T[],
+ ctx: VersionContext,
+ config: ChronicleConfig,
+): T[] {
+ if (ctx.dir !== null) {
+ return pages.filter((p) => isUnderPrefix(p.url, ctx.urlPrefix))
+ }
+ const prefixes = versionPrefixes(config)
+ return pages.filter((p) => !prefixes.some((pre) => isUnderPrefix(p.url, pre)))
+}
+
+function nodeUrls(node: Node): string[] {
+ if (node.type === 'page') return [node.url]
+ if (node.type === 'folder') {
+ const urls: string[] = []
+ if (node.index) urls.push(node.index.url)
+ for (const child of node.children) urls.push(...nodeUrls(child))
+ return urls
+ }
+ return []
+}
+
+function nodeMatchesVersion(
+ node: Node,
+ ctx: VersionContext,
+ config: ChronicleConfig,
+): boolean {
+ const urls = nodeUrls(node)
+ if (urls.length === 0) return ctx.dir === null
+ if (ctx.dir !== null) {
+ return urls.every((u) => isUnderPrefix(u, ctx.urlPrefix))
+ }
+ const prefixes = versionPrefixes(config)
+ return urls.every((u) => !prefixes.some((pre) => isUnderPrefix(u, pre)))
+}
+
+export function filterPageTreeByVersion(
+ tree: Root,
+ ctx: VersionContext,
+ config: ChronicleConfig,
+): Root {
+ if (ctx.dir !== null) {
+ const versionFolder = tree.children.find(
+ (n): n is Folder =>
+ n.type === 'folder' && nodeMatchesVersion(n, ctx, config),
+ )
+ return { ...tree, children: versionFolder ? versionFolder.children : [] }
+ }
+ return {
+ ...tree,
+ children: tree.children.filter((n) => nodeMatchesVersion(n, ctx, config)),
+ }
+}
+
+export function filterPageTreeByContentDir(
+ tree: Root,
+ ctx: VersionContext,
+ contentDir: string | null,
+): Root {
+ if (contentDir === null) return tree
+ const expectedPrefix = `${ctx.urlPrefix}/${contentDir}`
+ const match = tree.children.find((n): n is Folder => {
+ if (n.type !== 'folder') return false
+ const urls = nodeUrls(n)
+ return urls.length > 0 && urls.every((u) => isUnderPrefix(u, expectedPrefix))
+ })
+ if (!match) return { ...tree, children: [] }
+ return { ...tree, children: match.children }
+}
diff --git a/packages/chronicle/src/pages/ApiPage.tsx b/packages/chronicle/src/pages/ApiPage.tsx
index 84858c5..8143ea2 100644
--- a/packages/chronicle/src/pages/ApiPage.tsx
+++ b/packages/chronicle/src/pages/ApiPage.tsx
@@ -18,7 +18,7 @@ export function ApiPage({ slug }: ApiPageProps) {
<>
diff --git a/packages/chronicle/src/pages/DocsLayout.tsx b/packages/chronicle/src/pages/DocsLayout.tsx
index da59652..26cb0b7 100644
--- a/packages/chronicle/src/pages/DocsLayout.tsx
+++ b/packages/chronicle/src/pages/DocsLayout.tsx
@@ -1,17 +1,38 @@
import type { ReactNode } from 'react';
+import { useLocation } from 'react-router';
import { usePageContext } from '@/lib/page-context';
+import { getActiveContentDir } from '@/lib/navigation';
+import {
+ filterPageTreeByContentDir,
+ filterPageTreeByVersion,
+} from '@/lib/version-source';
import { getTheme } from '@/themes/registry';
interface DocsLayoutProps {
children: ReactNode;
+ hideSidebar?: boolean;
}
-export function DocsLayout({ children }: DocsLayoutProps) {
- const { config, tree } = usePageContext();
+export function DocsLayout({ children, hideSidebar }: DocsLayoutProps) {
+ const { config, tree, version } = usePageContext();
+ const { pathname } = useLocation();
const { Layout, className } = getTheme(config.theme?.name);
+ const activeContentDir = getActiveContentDir(pathname, config);
+ const versionScoped = filterPageTreeByVersion(tree, version, config);
+ const scopedTree = filterPageTreeByContentDir(
+ versionScoped,
+ version,
+ activeContentDir,
+ );
+
return (
-
+
{children}
);
diff --git a/packages/chronicle/src/pages/LandingPage.module.css b/packages/chronicle/src/pages/LandingPage.module.css
new file mode 100644
index 0000000..a18659b
--- /dev/null
+++ b/packages/chronicle/src/pages/LandingPage.module.css
@@ -0,0 +1,56 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-8);
+ padding: var(--rs-space-9) var(--rs-space-7);
+ max-width: 960px;
+ margin: 0 auto;
+}
+
+.title {
+ font-size: var(--rs-font-size-h3);
+ font-weight: 600;
+ color: var(--rs-color-foreground-base-primary);
+ margin: 0;
+}
+
+.description {
+ font-size: var(--rs-font-size-regular);
+ color: var(--rs-color-foreground-base-secondary);
+ margin: 0;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: var(--rs-space-6);
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-3);
+ padding: var(--rs-space-6);
+ border: 1px solid var(--rs-color-border-base-primary);
+ border-radius: var(--rs-radius-3);
+ text-decoration: none;
+ color: inherit;
+ background: var(--rs-color-background-base-primary);
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.card:hover {
+ border-color: var(--rs-color-border-accent-primary);
+ background: var(--rs-color-background-neutral-secondary);
+}
+
+.cardLabel {
+ font-size: var(--rs-font-size-large);
+ font-weight: 500;
+}
+
+.cardHref {
+ font-size: var(--rs-font-size-small);
+ color: var(--rs-color-foreground-base-secondary);
+ font-family: var(--rs-font-family-mono);
+}
diff --git a/packages/chronicle/src/pages/LandingPage.tsx b/packages/chronicle/src/pages/LandingPage.tsx
new file mode 100644
index 0000000..158840f
--- /dev/null
+++ b/packages/chronicle/src/pages/LandingPage.tsx
@@ -0,0 +1,39 @@
+import { Link as RouterLink } from 'react-router';
+import { getLandingEntries } from '@/lib/config';
+import { usePageContext } from '@/lib/page-context';
+import styles from './LandingPage.module.css';
+
+export function LandingPage() {
+ const { config, version } = usePageContext();
+ const entries = getLandingEntries(config, version.dir);
+
+ const heading = version.dir === null
+ ? config.site.title
+ : `${config.site.title} — ${versionLabel(config, version.dir)}`;
+
+ return (
+
+
{heading}
+ {config.site.description ? (
+
{config.site.description}
+ ) : null}
+
+ {entries.map((entry) => (
+
+ {entry.label}
+ {entry.href}
+
+ ))}
+
+
+ );
+}
+
+function versionLabel(
+ config: ReturnType['config'],
+ versionDir: string,
+): string {
+ return (
+ config.versions?.find((v) => v.dir === versionDir)?.label ?? versionDir
+ );
+}
diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx
index 05f8d81..16db22e 100644
--- a/packages/chronicle/src/server/App.tsx
+++ b/packages/chronicle/src/server/App.tsx
@@ -1,49 +1,47 @@
import '@raystack/apsara/normalize.css';
import '@raystack/apsara/style.css';
import { ThemeProvider } from '@raystack/apsara';
-import { useLocation } from 'react-router';
+import { Navigate, useLocation } from 'react-router';
import { Head } from '@/lib/head';
import { usePageContext } from '@/lib/page-context';
+import { resolveRoute, RouteType } from '@/lib/route-resolver';
import { ApiLayout } from '@/pages/ApiLayout';
import { ApiPage } from '@/pages/ApiPage';
import { DocsLayout } from '@/pages/DocsLayout';
import { DocsPage } from '@/pages/DocsPage';
+import { LandingPage } from '@/pages/LandingPage';
import type { ChronicleConfig } from '@/types';
import { getThemeConfig } from '@/themes/registry';
-function resolveRoute(pathname: string) {
- if (pathname.startsWith('/apis')) {
- const slug = pathname
- .replace(/^\/apis\/?/, '')
- .split('/')
- .filter(Boolean);
- return { type: 'api' as const, slug };
- }
-
- const slug =
- pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean);
- return { type: 'docs' as const, slug };
-}
-
export function App() {
const { pathname } = useLocation();
const { config } = usePageContext();
- const route = resolveRoute(pathname);
+ const route = resolveRoute(pathname, config);
const themeConfig = getThemeConfig(config.theme?.name);
+ if (route.type === RouteType.Redirect) {
+ return ;
+ }
+
+ const isApi =
+ route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage;
+ const apiSlug = route.type === RouteType.ApiPage ? route.slug : [];
+ const docsSlug = route.type === RouteType.DocsPage ? route.slug : [];
+ const isLanding = route.type === RouteType.DocsIndex;
+
return (
- {route.type === 'api' ? (
+ {isApi ? (
-
+
) : (
-
-
+
+ {isLanding ? : }
)}
@@ -53,16 +51,16 @@ export function App() {
function RootHead({ config }: { config: ChronicleConfig }) {
return (
| null = null;
-let cachedDocs: SearchDocument[] | null = null;
+const indexCache = new Map>();
+const docsCache = new Map();
+
+function keyFor(ctx: VersionContext): string {
+ return ctx.dir ?? '__latest__';
+}
function createIndex(docs: SearchDocument[]): MiniSearch {
const index = new MiniSearch({
@@ -31,8 +36,8 @@ function createIndex(docs: SearchDocument[]): MiniSearch {
return index;
}
-async function scanContent(): Promise {
- const pages = await getPages();
+async function scanContent(ctx: VersionContext): Promise {
+ const pages = await getPagesForVersion(ctx);
return pages.map(p => {
const fm = extractFrontmatter(p);
return {
@@ -45,12 +50,13 @@ async function scanContent(): Promise {
});
}
-async function buildApiDocs(): Promise {
+async function buildApiDocs(ctx: VersionContext): Promise {
const config = loadConfig();
- if (!config.api?.length) return [];
+ const apiConfigs = getApiConfigsForVersion(config, ctx.dir);
+ if (!apiConfigs.length) return [];
const docs: SearchDocument[] = [];
- const specs = await loadApiSpecs(config.api);
+ const specs = await loadApiSpecs(apiConfigs);
for (const spec of specs) {
const specSlug = getSpecSlug(spec);
@@ -60,7 +66,7 @@ async function buildApiDocs(): Promise {
for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
const op = pathItem[method] as OpenAPIV3.OperationObject | undefined;
if (!op?.operationId) continue;
- const url = `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`;
+ const url = `${ctx.urlPrefix}/apis/${specSlug}/${encodeURIComponent(op.operationId)}`;
docs.push({
id: url,
url,
@@ -75,29 +81,50 @@ async function buildApiDocs(): Promise {
return docs;
}
-async function getDocs(): Promise {
- if (cachedDocs) return cachedDocs;
+async function getDocs(ctx: VersionContext): Promise {
+ const key = keyFor(ctx);
+ const cached = docsCache.get(key);
+ if (cached) return cached;
const [contentDocs, apiDocs] = await Promise.all([
- scanContent(),
- buildApiDocs()
+ scanContent(ctx),
+ buildApiDocs(ctx)
]);
- cachedDocs = [...contentDocs, ...apiDocs];
- return cachedDocs;
+ const docs = [...contentDocs, ...apiDocs];
+ docsCache.set(key, docs);
+ return docs;
+}
+
+async function getIndex(ctx: VersionContext): Promise> {
+ const key = keyFor(ctx);
+ const cached = indexCache.get(key);
+ if (cached) return cached;
+ const docs = await getDocs(ctx);
+ const index = createIndex(docs);
+ indexCache.set(key, index);
+ return index;
}
-async function getIndex(): Promise> {
- if (searchIndex) return searchIndex;
- const docs = await getDocs();
- searchIndex = createIndex(docs);
- return searchIndex;
+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) {
+ throw new HTTPError({
+ status: 400,
+ message: `Unknown version tag: ${tag}`,
+ });
+ }
+ return { dir: version.dir, urlPrefix: `/${version.dir}` };
}
export default defineHandler(async event => {
const query = event.url.searchParams.get('query') ?? '';
- const index = await getIndex();
+ const tag = event.url.searchParams.get('tag');
+ const ctx = resolveCtx(tag);
+ const index = await getIndex(ctx);
if (!query) {
- const docs = await getDocs();
+ const docs = await getDocs(ctx);
return docs
.filter(d => d.type === 'page')
.slice(0, 8)
diff --git a/packages/chronicle/src/server/api/specs.ts b/packages/chronicle/src/server/api/specs.ts
index 3d4f1f7..00f6903 100644
--- a/packages/chronicle/src/server/api/specs.ts
+++ b/packages/chronicle/src/server/api/specs.ts
@@ -1,9 +1,21 @@
-import { defineHandler } from 'nitro';
-import { loadConfig } from '@/lib/config';
+import { defineHandler, HTTPError } from 'nitro';
+import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
import { loadApiSpecs } from '@/lib/openapi';
-export default defineHandler(async () => {
+export default defineHandler(async event => {
+ const versionParam = event.url.searchParams.get('version');
+ const versionDir = versionParam === null || versionParam === '' ? null : versionParam;
+
const config = loadConfig();
- const specs = config.api?.length ? await loadApiSpecs(config.api) : [];
- return specs;
+ if (versionDir && !config.versions?.some(v => v.dir === versionDir)) {
+ throw new HTTPError({
+ status: 400,
+ message: `Unknown version: ${versionDir}`,
+ });
+ }
+
+ const apiConfigs = getApiConfigsForVersion(config, versionDir);
+ if (!apiConfigs.length) return [];
+
+ return loadApiSpecs(apiConfigs);
});
diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx
index f972f85..141ae5f 100644
--- a/packages/chronicle/src/server/entry-client.tsx
+++ b/packages/chronicle/src/server/entry-client.tsx
@@ -4,7 +4,10 @@ import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
import { mdxComponents } from '@/components/mdx';
+import { getApiConfigsForVersion } from '@/lib/config';
import { PageProvider } from '@/lib/page-context';
+import { resolveRoute, RouteType } from '@/lib/route-resolver';
+import { resolveVersionFromUrl, type VersionContext } from '@/lib/version-source';
import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
import type { ApiSpec } from '@/lib/openapi';
import type { ReactNode } from 'react';
@@ -14,11 +17,17 @@ interface EmbeddedData {
config: ChronicleConfig;
tree: Root;
slug: string[];
+ version: VersionContext;
frontmatter: Frontmatter;
relativePath: string;
originalPath?: string;
}
+const defaultConfig: ChronicleConfig = {
+ site: { title: 'Documentation' },
+ content: [{ dir: 'docs', label: 'Docs' }],
+};
+
const contentModules = import.meta.glob<{ default?: React.ComponentType; toc?: TableOfContents }>(
'../../.content/**/*.{mdx,md}'
);
@@ -43,14 +52,28 @@ async function hydrate() {
window as unknown as { __PAGE_DATA__?: EmbeddedData }
).__PAGE_DATA__;
- const config: ChronicleConfig = embedded?.config ?? {
- title: 'Documentation'
- };
+ const config: ChronicleConfig = embedded?.config ?? defaultConfig;
const tree: Root = embedded?.tree ?? { name: 'root', children: [] };
- const isApiPage =
- window.location.pathname.startsWith('/apis') && !!config.api?.length;
- const apiSpecs: ApiSpec[] = isApiPage
- ? await fetch('/api/specs')
+
+ const route = resolveRoute(window.location.pathname, config);
+ // resolveVersionFromUrl always returns a valid context — even for redirect
+ // targets (e.g. /v1 -> /v1/docs) where route.version isn't on the union.
+ const routeVersion: VersionContext = resolveVersionFromUrl(
+ window.location.pathname,
+ config,
+ );
+ const version: VersionContext = embedded?.version ?? routeVersion;
+
+ const isApiRoute =
+ route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage;
+ const apiConfigs = isApiRoute
+ ? getApiConfigsForVersion(config, routeVersion.dir)
+ : [];
+ const specsUrl = routeVersion.dir
+ ? `/api/specs?version=${encodeURIComponent(routeVersion.dir)}`
+ : '/api/specs';
+ const apiSpecs: ApiSpec[] = apiConfigs.length
+ ? await fetch(specsUrl)
.then(r => r.json())
.catch(() => [])
: [];
@@ -73,6 +96,7 @@ async function hydrate() {
initialTree={tree}
initialPage={page}
initialApiSpecs={apiSpecs}
+ initialVersion={version}
loadMdx={loadMdxModule}
>
diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx
index fe23e1a..e5721d7 100644
--- a/packages/chronicle/src/server/entry-server.tsx
+++ b/packages/chronicle/src/server/entry-server.tsx
@@ -5,10 +5,11 @@ import { renderToReadableStream } from 'react-dom/server.edge';
import { StaticRouter } from 'react-router';
import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
import { mdxComponents } from '@/components/mdx';
-import { loadConfig } from '@/lib/config';
+import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
import { loadApiSpecs } from '@/lib/openapi';
import { PageProvider } from '@/lib/page-context';
-import { getPageTree, getPage, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
+import { resolveRoute, RouteType } from '@/lib/route-resolver';
+import { getPage, getPageTree, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
import { useNitroApp } from 'nitro/app';
import { App } from './App';
@@ -19,16 +20,30 @@ export default {
async fetch(req: Request) {
const url = new URL(req.url);
const pathname = url.pathname;
- const slug = pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean);
const config = loadConfig();
- const apiSpecs = config.api?.length
- ? await loadApiSpecs(config.api).catch(() => [])
+ const route = resolveRoute(pathname, config);
+
+ 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 },
+ });
+ }
+
+ const isApiRoute = route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage;
+ const pageSlug = route.type === RouteType.DocsPage ? route.slug : [];
+
+ const apiConfigs = isApiRoute
+ ? getApiConfigsForVersion(config, route.version.dir)
: [];
+ const apiSpecs = apiConfigs.length ? await loadApiSpecs(apiConfigs) : [];
const [tree, page] = await Promise.all([
getPageTree(),
- getPage(slug),
+ route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null),
]);
const relativePath = page ? getRelativePath(page) : null;
@@ -37,8 +52,8 @@ export default {
const pageData = page
? {
- slug,
- frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
+ slug: pageSlug,
+ frontmatter: extractFrontmatter(page, pageSlug[pageSlug.length - 1]),
content: mdxModule?.default
? React.createElement(mdxModule.default, { components: mdxComponents })
: null,
@@ -49,7 +64,8 @@ export default {
const embeddedData = {
config,
tree,
- slug,
+ slug: pageSlug,
+ version: route.version,
frontmatter: pageData?.frontmatter ?? null,
relativePath,
originalPath,
@@ -82,6 +98,7 @@ export default {
initialTree={tree}
initialPage={pageData}
initialApiSpecs={apiSpecs}
+ initialVersion={route.version}
loadMdx={async () => ({ content: null, toc: [] })}
>
@@ -95,9 +112,9 @@ export default {
const renderDuration = performance.now() - renderStart;
- const isApiRoute = pathname.startsWith('/apis');
- const status = !page && !isApiRoute && slug.length > 0 ? 404 : 200;
+ const status = route.type === RouteType.DocsPage && !page ? 404 : 200;
+ // biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook
useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
return new Response(stream, {
diff --git a/packages/chronicle/src/server/routes/[version]/llms.txt.ts b/packages/chronicle/src/server/routes/[version]/llms.txt.ts
new file mode 100644
index 0000000..b0e8603
--- /dev/null
+++ b/packages/chronicle/src/server/routes/[version]/llms.txt.ts
@@ -0,0 +1,30 @@
+import { getRouterParam } from 'h3';
+import { defineHandler, HTTPError } from 'nitro';
+import { loadConfig } from '@/lib/config';
+import { buildLlmsTxt } from '@/lib/llms';
+import { extractFrontmatter, getPagesForVersion } from '@/lib/source';
+
+export default defineHandler(async event => {
+ const config = loadConfig();
+
+ if (!config.llms?.enabled) {
+ throw new HTTPError({ status: 404, message: 'Not Found' });
+ }
+
+ const versionDir = getRouterParam(event, 'version');
+ const version = config.versions?.find(v => v.dir === versionDir);
+ if (!version) {
+ throw new HTTPError({ status: 404, message: 'Not Found' });
+ }
+
+ const ctx = { dir: version.dir, urlPrefix: `/${version.dir}` };
+ const pages = await getPagesForVersion(ctx);
+ const body = buildLlmsTxt(
+ config,
+ pages.map(p => ({ url: p.url, title: extractFrontmatter(p).title })),
+ ctx,
+ );
+
+ event.res.headers.set('Content-Type', 'text/plain');
+ return body;
+});
diff --git a/packages/chronicle/src/server/routes/llms.txt.ts b/packages/chronicle/src/server/routes/llms.txt.ts
index 46fb6e0..a0939f6 100644
--- a/packages/chronicle/src/server/routes/llms.txt.ts
+++ b/packages/chronicle/src/server/routes/llms.txt.ts
@@ -1,6 +1,8 @@
import { defineHandler, HTTPError } from 'nitro';
import { loadConfig } from '@/lib/config';
-import { getPages, extractFrontmatter } from '@/lib/source';
+import { buildLlmsTxt } from '@/lib/llms';
+import { extractFrontmatter, getPagesForVersion } from '@/lib/source';
+import { LATEST_CONTEXT } from '@/lib/version-source';
export default defineHandler(async event => {
const config = loadConfig();
@@ -9,13 +11,12 @@ export default defineHandler(async event => {
throw new HTTPError({ status: 404, message: 'Not Found' });
}
- const pages = await getPages();
- const index = pages.map(p => {
- const fm = extractFrontmatter(p);
- const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`;
- return `- [${fm.title}](${mdUrl})`;
- }).join('\n');
- const body = `# ${config.title}\n\n${config.description ?? ''}\n\n${index}`;
+ 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;
diff --git a/packages/chronicle/src/server/routes/og.tsx b/packages/chronicle/src/server/routes/og.tsx
index 2fb15ca..30d647e 100644
--- a/packages/chronicle/src/server/routes/og.tsx
+++ b/packages/chronicle/src/server/routes/og.tsx
@@ -22,9 +22,9 @@ async function loadFont(): Promise {
export default defineHandler(async event => {
const config = loadConfig();
- const title = event.url.searchParams.get('title') ?? config.title;
+ const title = event.url.searchParams.get('title') ?? config.site.title;
const description = event.url.searchParams.get('description') ?? '';
- const siteName = config.title;
+ const siteName = config.site.title;
const font = await loadFont();
diff --git a/packages/chronicle/src/server/routes/sitemap.xml.ts b/packages/chronicle/src/server/routes/sitemap.xml.ts
index ea93437..41ecf65 100644
--- a/packages/chronicle/src/server/routes/sitemap.xml.ts
+++ b/packages/chronicle/src/server/routes/sitemap.xml.ts
@@ -1,6 +1,6 @@
import { defineHandler } from 'nitro';
import { buildApiRoutes } from '@/lib/api-routes';
-import { loadConfig } from '@/lib/config';
+import { getAllVersions, getApiConfigsForVersion, loadConfig } from '@/lib/config';
import { loadApiSpecs } from '@/lib/openapi';
import { getPages } from '@/lib/source';
@@ -23,11 +23,19 @@ export default defineHandler(async event => {
return `${baseUrl}/${page.slugs.join('/')}${lastmod}`;
});
- const apiPages = config.api?.length
- ? buildApiRoutes(await loadApiSpecs(config.api)).map(
- route => `${baseUrl}/apis/${route.slug.join('/')}`
- )
- : [];
+ const apiPages: string[] = [];
+ for (const v of getAllVersions(config)) {
+ const versionDir = v.isLatest ? null : v.dir;
+ const apiConfigs = getApiConfigsForVersion(config, versionDir);
+ if (!apiConfigs.length) continue;
+ const prefix = versionDir ? `/${versionDir}` : '';
+ const routes = buildApiRoutes(await loadApiSpecs(apiConfigs));
+ for (const route of routes) {
+ apiPages.push(
+ `${baseUrl}${prefix}/apis/${route.slug.join('/')}`,
+ );
+ }
+ }
const xml = `
diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts
index 578e121..78819e7 100644
--- a/packages/chronicle/src/server/vite-config.ts
+++ b/packages/chronicle/src/server/vite-config.ts
@@ -18,7 +18,6 @@ function resolveOutputDir(projectRoot: string, preset?: string): string {
export interface ViteConfigOptions {
packageRoot: string;
projectRoot: string;
- contentDir: string;
configPath?: string;
preset?: string;
}
@@ -41,8 +40,9 @@ async function readChronicleConfig(projectRoot: string, configPath?: string): Pr
export async function createViteConfig(
options: ViteConfigOptions
): Promise {
- const { packageRoot, projectRoot, contentDir, configPath, preset } = options;
+ const { packageRoot, projectRoot, configPath, preset } = options;
const rawConfig = await readChronicleConfig(projectRoot, configPath);
+ const contentMirror = path.resolve(packageRoot, '.content');
return {
root: packageRoot,
@@ -98,11 +98,11 @@ export async function createViteConfig(
},
server: {
fs: {
- allow: [packageRoot, projectRoot, contentDir]
+ allow: [packageRoot, projectRoot, contentMirror]
}
},
define: {
- __CHRONICLE_CONTENT_DIR__: JSON.stringify(contentDir),
+ __CHRONICLE_CONTENT_DIR__: JSON.stringify(contentMirror),
__CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
__CHRONICLE_CONFIG_RAW__: JSON.stringify(rawConfig),
},
diff --git a/packages/chronicle/src/themes/default/ContentDirButtons.tsx b/packages/chronicle/src/themes/default/ContentDirButtons.tsx
new file mode 100644
index 0000000..8513e46
--- /dev/null
+++ b/packages/chronicle/src/themes/default/ContentDirButtons.tsx
@@ -0,0 +1,64 @@
+import { ChevronDownIcon } from '@heroicons/react/24/outline';
+import { Button, DropdownMenu, Flex } from '@raystack/apsara';
+import { Link as RouterLink, useLocation, useNavigate } from 'react-router';
+import { getLandingEntries } from '@/lib/config';
+import { getActiveContentDir, splitContentButtons } from '@/lib/navigation';
+import { usePageContext } from '@/lib/page-context';
+
+const MAX_VISIBLE = 3;
+
+export function ContentDirButtons() {
+ const { config, version } = usePageContext();
+ const { pathname } = useLocation();
+ const navigate = useNavigate();
+
+ const entries = getLandingEntries(config, version.dir);
+ if (entries.length <= 1) return null;
+
+ const active = getActiveContentDir(pathname, config);
+ const { visible, overflow } = splitContentButtons(entries, MAX_VISIBLE);
+
+ return (
+
+ {visible.map(entry => (
+
+
+
+ ))}
+ {overflow.length > 0 ? (
+
+
+ }
+ >
+ More
+
+
+
+ {overflow.map(entry => (
+ navigate(entry.href)}
+ >
+ {entry.label}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/packages/chronicle/src/themes/default/Layout.tsx b/packages/chronicle/src/themes/default/Layout.tsx
index 625784d..3c8f1e7 100644
--- a/packages/chronicle/src/themes/default/Layout.tsx
+++ b/packages/chronicle/src/themes/default/Layout.tsx
@@ -16,7 +16,9 @@ import { Footer } from '@/components/ui/footer';
import { Search } from '@/components/ui/search';
import type { Node } from 'fumadocs-core/page-tree';
import type { ThemeLayoutProps } from '@/types';
+import { ContentDirButtons } from './ContentDirButtons';
import styles from './Layout.module.css';
+import { VersionSwitcher } from './VersionSwitcher';
const iconMap: Record = {
'rectangle-stack': ,
@@ -33,6 +35,7 @@ export function Layout({
children,
config,
tree,
+ hideSidebar,
classNames
}: ThemeLayoutProps) {
const { pathname } = useLocation();
@@ -65,12 +68,14 @@ export function Layout({
style={{ textDecoration: 'none', color: 'inherit' }}
>
- {config.title}
+ {config.site.title}
+
+
{config.api?.map(api => (
-
-
- {tree.children.map((item, i) => (
-
- ))}
-
-
+ {hideSidebar ? null : (
+
+
+ {tree.children.map((item, i) => (
+
+ ))}
+
+
+ )}
{children}
diff --git a/packages/chronicle/src/themes/default/VersionSwitcher.tsx b/packages/chronicle/src/themes/default/VersionSwitcher.tsx
new file mode 100644
index 0000000..3bce5d6
--- /dev/null
+++ b/packages/chronicle/src/themes/default/VersionSwitcher.tsx
@@ -0,0 +1,57 @@
+import { ChevronDownIcon } from '@heroicons/react/24/outline';
+import { Badge, Button, DropdownMenu, Flex } from '@raystack/apsara';
+import { useNavigate } from 'react-router';
+import { getAllVersions } from '@/lib/config';
+import { getVersionHomeHref } from '@/lib/navigation';
+import { usePageContext } from '@/lib/page-context';
+
+export function VersionSwitcher() {
+ const { config, version } = usePageContext();
+ const navigate = useNavigate();
+
+ if (!config.versions?.length) return null;
+
+ const versions = getAllVersions(config);
+ const active = versions.find(v =>
+ v.isLatest ? version.dir === null : v.dir === version.dir,
+ );
+
+ return (
+
+
+ }
+ >
+
+ {active?.label ?? 'Version'}
+ {active?.badge ? (
+
+ {active.badge.label}
+
+ ) : null}
+
+
+
+
+ {versions.map(v => (
+ navigate(getVersionHomeHref(config, v.dir))}
+ >
+
+ {v.label}
+ {v.badge ? (
+
+ {v.badge.label}
+
+ ) : null}
+
+
+ ))}
+
+
+ );
+}
diff --git a/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx b/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx
new file mode 100644
index 0000000..714a8cd
--- /dev/null
+++ b/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx
@@ -0,0 +1,45 @@
+import { ChevronDownIcon } from '@heroicons/react/24/outline';
+import { Button, DropdownMenu } from '@raystack/apsara';
+import { useLocation, useNavigate } from 'react-router';
+import { getLandingEntries } from '@/lib/config';
+import { getActiveContentDir } from '@/lib/navigation';
+import { usePageContext } from '@/lib/page-context';
+
+export function ContentDirDropdown() {
+ const { config, version } = usePageContext();
+ const { pathname } = useLocation();
+ const navigate = useNavigate();
+
+ const entries = getLandingEntries(config, version.dir);
+ if (entries.length <= 1) return null;
+
+ const activeDir = getActiveContentDir(pathname, config);
+ const activeEntry =
+ entries.find(e => e.contentDir === activeDir) ?? entries[0];
+
+ return (
+
+
+ }
+ >
+ {activeEntry.label}
+
+
+
+ {entries.map(entry => (
+ navigate(entry.href)}
+ >
+ {entry.label}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/chronicle/src/themes/paper/Layout.module.css b/packages/chronicle/src/themes/paper/Layout.module.css
index 673716e..8054a61 100644
--- a/packages/chronicle/src/themes/paper/Layout.module.css
+++ b/packages/chronicle/src/themes/paper/Layout.module.css
@@ -25,6 +25,13 @@
margin-bottom: var(--rs-space-7);
}
+.nav {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-3);
+ margin-bottom: var(--rs-space-7);
+}
+
.content {
flex: 1;
overflow-y: auto;
diff --git a/packages/chronicle/src/themes/paper/Layout.tsx b/packages/chronicle/src/themes/paper/Layout.tsx
index 805dcd2..604f2bc 100644
--- a/packages/chronicle/src/themes/paper/Layout.tsx
+++ b/packages/chronicle/src/themes/paper/Layout.tsx
@@ -5,28 +5,37 @@ import { cx } from 'class-variance-authority';
import { Footer } from '@/components/ui/footer';
import type { ThemeLayoutProps } from '@/types';
import { ChapterNav } from './ChapterNav';
+import { ContentDirDropdown } from './ContentDirDropdown';
import styles from './Layout.module.css';
+import { VersionSwitcher } from './VersionSwitcher';
export function Layout({
children,
config,
tree,
+ hideSidebar,
classNames
}: ThemeLayoutProps) {
return (
-
+ {hideSidebar ? null : (
+
+ )}
{children}
diff --git a/packages/chronicle/src/themes/paper/VersionSwitcher.tsx b/packages/chronicle/src/themes/paper/VersionSwitcher.tsx
new file mode 100644
index 0000000..ac845e2
--- /dev/null
+++ b/packages/chronicle/src/themes/paper/VersionSwitcher.tsx
@@ -0,0 +1,58 @@
+import { ChevronDownIcon } from '@heroicons/react/24/outline';
+import { Badge, Button, DropdownMenu, Flex } from '@raystack/apsara';
+import { useNavigate } from 'react-router';
+import { getAllVersions } from '@/lib/config';
+import { getVersionHomeHref } from '@/lib/navigation';
+import { usePageContext } from '@/lib/page-context';
+
+export function VersionSwitcher() {
+ const { config, version } = usePageContext();
+ const navigate = useNavigate();
+
+ if (!config.versions?.length) return null;
+
+ const versions = getAllVersions(config);
+ const active = versions.find(v =>
+ v.isLatest ? version.dir === null : v.dir === version.dir,
+ );
+
+ return (
+
+
+ }
+ >
+
+ {active?.label ?? 'Version'}
+ {active?.badge ? (
+
+ {active.badge.label}
+
+ ) : null}
+
+
+
+
+ {versions.map(v => (
+ navigate(getVersionHomeHref(config, v.dir))}
+ >
+
+ {v.label}
+ {v.badge ? (
+
+ {v.badge.label}
+
+ ) : null}
+
+
+ ))}
+
+
+ );
+}
diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts
index f28a322..72e3916 100644
--- a/packages/chronicle/src/types/config.ts
+++ b/packages/chronicle/src/types/config.ts
@@ -1,3 +1,4 @@
+import uniqBy from 'lodash/uniqBy.js'
import { z } from 'zod'
const logoSchema = z.object({
@@ -73,24 +74,156 @@ const telemetrySchema = z.object({
port: z.number().int().min(1).max(65535).default(9090),
})
-export const chronicleConfigSchema = z.object({
+const siteSchema = z.object({
title: z.string(),
description: z.string().optional(),
- url: z.string().optional(),
- content: z.string().optional(),
- preset: z.string().optional(),
- logo: logoSchema.optional(),
- theme: themeSchema.optional(),
- navigation: navigationSchema.optional(),
- search: searchSchema.optional(),
- footer: footerSchema.optional(),
+})
+
+const DIR_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
+
+const dirNameSchema = z
+ .string()
+ .min(1)
+ .refine((s) => DIR_NAME_PATTERN.test(s) && s !== '.' && s !== '..', {
+ message:
+ 'dir must start with a letter or digit and contain only letters, digits, ".", "_", or "-"',
+ })
+
+const contentEntrySchema = z.object({
+ dir: dirNameSchema,
+ label: z.string().min(1),
+})
+
+// Variants map to Apsara Badge color prop.
+// https://apsara.raystack.org/docs/components/badge
+const badgeVariantSchema = z.enum([
+ 'accent',
+ 'warning',
+ 'danger',
+ 'success',
+ 'neutral',
+ 'gradient',
+])
+
+const badgeSchema = z.object({
+ label: z.string().min(1),
+ variant: badgeVariantSchema.default('accent'),
+})
+
+const latestSchema = z.object({
+ label: z.string().min(1),
+ landing: z.boolean().optional(),
+})
+
+const versionSchema = z.object({
+ dir: dirNameSchema,
+ label: z.string().min(1),
+ badge: badgeSchema.optional(),
+ landing: z.boolean().optional(),
+ content: z.array(contentEntrySchema).min(1),
api: z.array(apiSchema).optional(),
- llms: llmsSchema.optional(),
- analytics: analyticsSchema.optional(),
- telemetry: telemetrySchema.optional(),
})
+const allUnique = (items: T[], key: (item: T) => string): boolean =>
+ uniqBy(items, key).length === items.length
+
+const RESERVED_ROUTE_SEGMENTS = [
+ 'api',
+ 'apis',
+ 'og',
+ 'llms.txt',
+ 'robots.txt',
+ 'sitemap.xml',
+] as const
+
+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'],
+ })
+ .refine(
+ (cfg) => {
+ if (!cfg.versions) return true
+ const contentDirs = new Set(cfg.content.map((c) => c.dir))
+ return !cfg.versions.some((v) => contentDirs.has(v.dir))
+ },
+ {
+ message:
+ 'versions[].dir must not overlap with content[].dir — the URL segment would be shadowed',
+ path: ['versions'],
+ },
+ )
+ .superRefine((cfg, ctx) => {
+ const reserved = new Set(RESERVED_ROUTE_SEGMENTS)
+ const message = `dir must not be a reserved route segment: ${RESERVED_ROUTE_SEGMENTS.join(', ')}`
+
+ cfg.content.forEach((c, i) => {
+ if (reserved.has(c.dir)) {
+ ctx.addIssue({ code: 'custom', message, path: ['content', i, 'dir'] })
+ }
+ })
+ cfg.versions?.forEach((v, vi) => {
+ if (reserved.has(v.dir)) {
+ ctx.addIssue({
+ code: 'custom',
+ message,
+ path: ['versions', vi, 'dir'],
+ })
+ }
+ v.content.forEach((c, ci) => {
+ if (reserved.has(c.dir)) {
+ ctx.addIssue({
+ code: 'custom',
+ message,
+ path: ['versions', vi, 'content', ci, 'dir'],
+ })
+ }
+ })
+ })
+ })
+
export type ChronicleConfig = z.infer
+export type SiteConfig = z.infer
+export type ContentEntry = z.infer
+export type BadgeConfig = z.infer
+export type BadgeVariant = z.infer
+export type LatestConfig = z.infer
+export type VersionConfig = z.infer
export type LogoConfig = z.infer
export type ThemeConfig = z.infer
export type NavigationConfig = z.infer
diff --git a/packages/chronicle/src/types/theme.ts b/packages/chronicle/src/types/theme.ts
index c948bd1..cfcdd68 100644
--- a/packages/chronicle/src/types/theme.ts
+++ b/packages/chronicle/src/types/theme.ts
@@ -7,6 +7,7 @@ export interface ThemeLayoutProps {
children: ReactNode
config: ChronicleConfig
tree: Root
+ hideSidebar?: boolean
classNames?: { layout?: string; body?: string; sidebar?: string; content?: string }
}
diff --git a/vercel.json b/vercel.json
index 47e057a..0ee2ca7 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,5 +1,5 @@
{
"installCommand": "bun install",
"buildCommand": "bun run build:cli && bun run build:docs -- --preset vercel",
- "outputDirectory": ".vercel/output"
+ "outputDirectory": "docs/.vercel/output"
}