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 ? ( + + + + + + {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 ( + + + + + + {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 ( + + + + + + {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 ( + + + + + + {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" }