From b0f8a800439d4d3b55a50c6c01987cfec9bb2e51 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 24 Jun 2026 14:05:40 +0200 Subject: [PATCH 1/2] add header search bar with Cmd+K dialog --- package.json | 2 + pnpm-lock.yaml | 454 ++++++++++++++++++++++ src/app/layout.tsx | 15 +- src/components/search/search-dialog.tsx | 203 ++++++++++ src/components/search/search-provider.tsx | 76 ++++ src/components/search/search-trigger.tsx | 61 +++ src/components/site-header.tsx | 25 +- src/lib/search/buildIndex.ts | 234 +++++++++++ src/lib/search/types.ts | 32 ++ 9 files changed, 1088 insertions(+), 14 deletions(-) create mode 100644 src/components/search/search-dialog.tsx create mode 100644 src/components/search/search-provider.tsx create mode 100644 src/components/search/search-trigger.tsx create mode 100644 src/lib/search/buildIndex.ts create mode 100644 src/lib/search/types.ts diff --git a/package.json b/package.json index 2807bfd1..a58efbb1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@react-three/fiber": "^9.6.1", "@vercel/analytics": "^2.0.1", + "cmdk": "^1.1.1", + "fuse.js": "^7.4.2", "ioredis": "^5.11.1", "js-yaml": "^4.1.1", "lucide-react": "^1.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec4c0fa2..0d7caede 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + fuse.js: + specifier: ^7.4.2 + version: 7.4.2 ioredis: specifier: ^5.11.1 version: 5.11.1 @@ -653,6 +659,177 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.17': + resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} + 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 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.13': + resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} + 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 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.10': + resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} + 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 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.12': + resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} + 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 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + 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 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.6': + resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} + 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 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.2': + resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@react-three/fiber@9.6.1': resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} peerDependencies: @@ -1077,6 +1254,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1215,6 +1396,12 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1318,6 +1505,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1614,6 +1804,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuse.js@7.4.2: + resolution: {integrity: sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==} + engines: {node: '>=10'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -1630,6 +1824,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2266,6 +2464,36 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-use-measure@2.1.7: resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} peerDependencies: @@ -2576,6 +2804,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -3114,6 +3362,148 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@radix-ui/primitive@1.1.4': {} + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.3.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0)': dependencies: '@babel/runtime': 7.29.7 @@ -3473,6 +3863,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -3642,6 +4036,18 @@ snapshots: cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3725,6 +4131,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4216,6 +4624,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.4.2: {} + generator-function@2.0.1: {} generic-pool@3.9.0: {} @@ -4235,6 +4645,8 @@ snapshots: hasown: 2.0.3 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -4828,6 +5240,33 @@ snapshots: react-is@16.13.1: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -5266,6 +5705,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + use-sync-external-store@1.6.0(react@19.2.4): dependencies: react: 19.2.4 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 227ef851..28108fb1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,8 @@ import { Analytics } from "@vercel/analytics/next"; import "./globals.css"; import { SiteHeader } from "@/components/site-header"; import { SiteFooter } from "@/components/site-footer"; +import { SearchProvider } from "@/components/search/search-provider"; +import { buildSearchIndex } from "@/lib/search/buildIndex"; import { SITE } from "@/data/site"; import { safeJsonLd } from "@/lib/jsonld"; @@ -140,9 +142,12 @@ const ORG_JSONLD = { ], }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { + // Index is built once per server runtime (memoised via React `cache`), + // shipped as JSON to the client provider. ~400 docs, ~25-40 KB raw. + const searchItems = await buildSearchIndex(); return ( Skip to main content - -
{children}
- + + +
{children}
+ +
{process.env.VERCEL_ENV === "production" && } diff --git a/src/components/search/search-dialog.tsx b/src/components/search/search-dialog.tsx new file mode 100644 index 00000000..d4a67413 --- /dev/null +++ b/src/components/search/search-dialog.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { Command } from "cmdk"; +import Fuse from "fuse.js"; +import { Search, X } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { SearchItem, SearchKind } from "@/lib/search/types"; + +type Props = { + items: SearchItem[]; + onClose: () => void; +}; + +const KIND_ORDER: SearchKind[] = [ + "Benchmark", + "Product", + "Compare", + "Alternative", + "Answer", + "Chain", + "Page", +]; + +/** + * Hardcoded "popular benches" shown when the query is empty. No + * analytics involved, just an editorial pick of high-traffic specs + * that map to known SEO winners. Update by hand when traffic shifts. + */ +const POPULAR_BENCH_SLUGS = [ + "pm-data-freshness", + "aggregator-head-lag", + "l1-finality", + "rpc-capabilities", +]; + +export default function SearchDialog({ items, onClose }: Props) { + const router = useRouter(); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + // One Fuse instance per dialog mount. The full corpus is ~400 docs + // so build cost is sub-millisecond, no need to memoise across mounts. + const fuse = useMemo( + () => + new Fuse(items, { + keys: [ + { name: "title", weight: 0.6 }, + { name: "tags", weight: 0.3 }, + { name: "description", weight: 0.1 }, + ], + threshold: 0.35, + minMatchCharLength: 2, + ignoreLocation: true, + }), + [items], + ); + + // Body scroll lock + ESC handler, same pattern as report-section-modal. + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => { + document.body.style.overflow = prev; + window.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + useEffect(() => { + // cmdk autofocuses its own input, but giving the ref-driven focus + // a tick of priority avoids a flash where typing the first letter + // gets eaten by the trigger button's focus. + const t = window.setTimeout(() => inputRef.current?.focus(), 0); + return () => window.clearTimeout(t); + }, []); + + const results = useMemo(() => { + const q = query.trim(); + if (!q) { + const popular = POPULAR_BENCH_SLUGS + .map((slug) => items.find((it) => it.id === `bench:${slug}`)) + .filter((it): it is SearchItem => Boolean(it)); + return popular; + } + return fuse.search(q, { limit: 8 }).map((r) => r.item); + }, [query, fuse, items]); + + const grouped = useMemo(() => { + const map = new Map(); + for (const it of results) { + const list = map.get(it.kind) ?? []; + list.push(it); + map.set(it.kind, list); + } + return KIND_ORDER + .map((kind) => ({ kind, list: map.get(kind) ?? [] })) + .filter((g) => g.list.length > 0); + }, [results]); + + function go(url: string) { + onClose(); + router.push(url); + } + + const trimmed = query.trim(); + const showEmpty = trimmed.length > 0 && results.length === 0; + const headerLabel = trimmed.length === 0 ? "Popular benchmarks" : null; + + return ( +
+
e.stopPropagation()} + > + +
+ + + +
+ + + {headerLabel && ( +
+ {headerLabel} +
+ )} + + {showEmpty && ( + + No results for "{trimmed}". Try searching by bench name, + provider, chain, or keyword. + + )} + + {grouped.map(({ kind, list }) => ( + 0 ? kind : undefined} + className="px-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.18em] [&_[cmdk-group-heading]]:text-ink-faint" + > + {list.map((it) => ( + go(it.url)} + className="flex items-start gap-3 rounded px-2 py-2 cursor-pointer text-sm aria-selected:bg-paper-soft" + > + + {it.kind} + + + + {it.title} + + {it.description && ( + + {it.description} + + )} + + + ))} + + ))} +
+ +
+ Enter to go + Esc to close +
+
+
+
+ ); +} diff --git a/src/components/search/search-provider.tsx b/src/components/search/search-provider.tsx new file mode 100644 index 00000000..cd9cfa4a --- /dev/null +++ b/src/components/search/search-provider.tsx @@ -0,0 +1,76 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { SearchItem } from "@/lib/search/types"; + +type Ctx = { + open: () => void; +}; + +const SearchCtx = createContext(null); + +/** + * Lazy-load the dialog (and with it, cmdk + Fuse.js) on the first open. + * Keeps the dialog out of the initial JS payload so the header trigger + * is cheap to ship sitewide. + */ +const SearchDialog = dynamic(() => import("@/components/search/search-dialog"), { + ssr: false, +}); + +type ProviderProps = { + items: SearchItem[]; + children: React.ReactNode; +}; + +export function SearchProvider({ items, children }: ProviderProps) { + const [isOpen, setIsOpen] = useState(false); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + + // Global keyboard shortcuts. Cmd+K (mac) / Ctrl+K (win/linux) toggle + // the dialog. `/` opens it the way GitHub does — but only when the + // active element is not already an input, so typing `/` in a search + // box on a bench page doesn't fire twice. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setIsOpen((v) => !v); + return; + } + if (e.key === "/" && !isOpen) { + const t = e.target as HTMLElement | null; + const tag = t?.tagName; + const editable = t?.isContentEditable; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || editable) return; + e.preventDefault(); + setIsOpen(true); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen]); + + const value = useMemo(() => ({ open }), [open]); + + return ( + + {children} + {isOpen && } + + ); +} + +export function useSearch(): Ctx { + const ctx = useContext(SearchCtx); + if (!ctx) { + // Soft fallback: when something tries to open the dialog outside + // the provider (shouldn't happen, but cheap to guard) we make the + // trigger inert rather than crash the page. + return { open: () => {} }; + } + return ctx; +} diff --git a/src/components/search/search-trigger.tsx b/src/components/search/search-trigger.tsx new file mode 100644 index 00000000..02af3dfc --- /dev/null +++ b/src/components/search/search-trigger.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Search } from "lucide-react"; +import { useSyncExternalStore } from "react"; +import { useSearch } from "@/components/search/search-provider"; + +const emptySubscribe = () => () => {}; + +function readIsMac(): boolean { + if (typeof navigator === "undefined") return false; + const p = navigator.platform || navigator.userAgent; + return /Mac|iPhone|iPad|iPod/i.test(p); +} + +type Props = { + /** + * - `desktop`: inline pill-ish button next to "GitHub" / theme toggle + * in the desktop nav. Hidden below md. + * - `mobile`: 44×44 icon-only square sized to the same min-tap target + * as the hamburger. Hidden at md+. + */ + variant: "desktop" | "mobile"; +}; + +export function SearchTrigger({ variant }: Props) { + const { open } = useSearch(); + // Read once on the client. SSR snapshot is `false` so we render the + // Ctrl variant; the client snapshot replaces it on hydration if the + // platform actually is macOS. Avoids the set-state-in-effect lint + // and stays consistent with how ThemeToggle reads the DOM. + const isMac = useSyncExternalStore(emptySubscribe, readIsMac, () => false); + + if (variant === "mobile") { + return ( + + ); + } + + const shortcut = isMac ? "⌘K" : "Ctrl K"; + return ( + + ); +} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index f238c6c1..81d97add 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -3,6 +3,7 @@ import { Menu, X } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { SearchTrigger } from "@/components/search/search-trigger"; import { SiteLogoSwitcher } from "@/components/site-logo-switcher"; import { ThemeToggle } from "@/components/theme-toggle"; @@ -73,6 +74,7 @@ export function SiteHeader() { {item.label} ))} + | - +
+ + +
{open && ( diff --git a/src/lib/search/buildIndex.ts b/src/lib/search/buildIndex.ts new file mode 100644 index 00000000..85f0974f --- /dev/null +++ b/src/lib/search/buildIndex.ts @@ -0,0 +1,234 @@ +/** + * Server-only search index builder. + * + * Walks every editorial surface the site exposes (benchmarks, products, + * alternatives, answers, chains, compare pairs, top-level pages) and + * emits one flat `SearchItem[]` for the client-side Fuse.js instance. + * + * Build-safe by design: only reads YAML specs and TypeScript registries. + * Never touches Prometheus, KV, or any network — so calling this from + * `app/layout.tsx` is cheap even on a cold build with no live data. + */ + +import { cache } from "react"; +import { loadSpecsUncached } from "@/lib/materialize/load"; +import { loadAllAlternatives } from "@/lib/alternatives"; +import { loadAllAnswers } from "@/lib/answers"; +import { CHAINS } from "@/lib/chains"; +import { COMPARE_PAIRS } from "@/data/compare-pairs"; +import { PROVIDER_REGISTRY } from "@/data/provider-registry"; +import { canonicalize } from "@/lib/providers"; +import type { SearchItem } from "@/lib/search/types"; + +/** + * Top-level routes that aren't enumerated elsewhere. Hardcoded because + * there are 14 of them and the list changes maybe once a quarter; an + * auto-scanner over `src/app` would bring more noise than value. + */ +const STATIC_PAGES: Array<{ url: string; title: string; description: string; tags?: string[] }> = [ + { + url: "/", + title: "Home", + description: "OpenChainBench. Open benchmarks for crypto infrastructure.", + }, + { + url: "/benchmarks", + title: "All benchmarks", + description: "Every live benchmark, grouped by category.", + tags: ["index", "catalog"], + }, + { + url: "/products", + title: "All products", + description: "Every product tracked by OpenChainBench, grouped by performance.", + tags: ["providers", "directory"], + }, + { + url: "/answers", + title: "Answers", + description: "Question-driven landing pages backed by live benchmark data.", + tags: ["questions", "faq"], + }, + { + url: "/alternatives", + title: "Alternatives", + description: "Head-to-head alternatives pages for popular crypto products.", + }, + { + url: "/chains", + title: "Chains", + description: "Per-chain hubs that aggregate every benchmark for a given L1 or L2.", + }, + { + url: "/compare", + title: "Compare", + description: "Side-by-side comparisons for two providers across shared benchmarks.", + tags: ["versus", "vs"], + }, + { + url: "/methodology", + title: "Methodology", + description: "How OpenChainBench runs harnesses, scrapes Prometheus, and ranks providers.", + }, + { + url: "/about", + title: "About", + description: "What OpenChainBench is, who runs it, and why it exists.", + }, + { + url: "/contribute", + title: "Contribute", + description: "How to add a new benchmark, harness, or provider.", + }, + { + url: "/press", + title: "Press", + description: "Brand assets, logos, and press contact.", + }, + { + url: "/partners", + title: "Partners", + description: "Organisations supporting OpenChainBench.", + }, + { + url: "/hyperliquid", + title: "Hyperliquid hub", + description: "Every benchmark that touches Hyperliquid, in one place.", + tags: ["perps", "hl"], + }, + { + url: "/perps", + title: "Perps hub", + description: "Perpetual exchanges leaderboards and venue stats.", + tags: ["derivatives", "futures"], + }, + { + url: "/prediction-markets", + title: "Prediction markets", + description: "Prediction market benchmarks across Polymarket and competitors.", + tags: ["polymarket"], + }, + { + url: "/mcp", + title: "MCP", + description: "Model Context Protocol endpoint and tool surface.", + tags: ["model context protocol", "ai"], + }, +]; + +function truncate(s: string | undefined, n = 150): string | undefined { + if (!s) return undefined; + const clean = s.replace(/\s+/g, " ").trim(); + if (clean.length <= n) return clean; + return `${clean.slice(0, n - 1).trimEnd()}…`; +} + +function providerNameFor(slug: string): string { + // canonicalize() handles aliases (helius-sender → helius-sender, but + // also covers historical renames) and falls back to title-casing the + // slug when no display name is registered. + return canonicalize(slug).name; +} + +/** + * Build the full index. Cached for the lifetime of the React server + * runtime so repeated layout renders within a single deploy share work. + */ +export const buildSearchIndex = cache(async function buildSearchIndex(): Promise { + const [specs, alternatives, answers] = await Promise.all([ + loadSpecsUncached(), + loadAllAlternatives(), + loadAllAnswers(), + ]); + + const items: SearchItem[] = []; + + // ── Benchmarks ────────────────────────────────────────────────── + for (const spec of specs) { + if (spec.status === "draft") continue; + items.push({ + id: `bench:${spec.slug}`, + kind: "Benchmark", + title: spec.title, + url: `/benchmarks/${spec.slug}`, + description: truncate(spec.seo_description ?? spec.subtitle), + tags: [spec.category, spec.slug], + }); + } + + // ── Products ──────────────────────────────────────────────────── + for (const [slug, entry] of Object.entries(PROVIDER_REGISTRY)) { + items.push({ + id: `product:${slug}`, + kind: "Product", + title: providerNameFor(slug), + url: `/products/${slug}`, + description: truncate(entry.description), + tags: [slug, ...(entry.chains ?? [])], + }); + } + + // ── Alternatives ──────────────────────────────────────────────── + for (const alt of alternatives) { + items.push({ + id: `alt:${alt.slug}`, + kind: "Alternative", + title: alt.seo_title ?? `${alt.target_product} alternatives`, + url: `/alternatives/${alt.slug}`, + description: truncate(alt.description), + tags: [alt.target_product, alt.slug, alt.benchmark], + }); + } + + // ── Answers ───────────────────────────────────────────────────── + for (const ans of answers) { + items.push({ + id: `answer:${ans.slug}`, + kind: "Answer", + title: ans.question, + url: `/answers/${ans.slug}`, + description: truncate(ans.seo_description ?? ans.short_answer), + tags: [ans.slug, ans.benchmark], + }); + } + + // ── Chains ────────────────────────────────────────────────────── + for (const chain of CHAINS) { + items.push({ + id: `chain:${chain.slug}`, + kind: "Chain", + title: chain.label, + url: `/chains/${chain.slug}`, + description: truncate(chain.description), + tags: [chain.slug, chain.category, chain.nativeSymbol ?? ""].filter(Boolean), + }); + } + + // ── Compare pairs ─────────────────────────────────────────────── + for (const pair of COMPARE_PAIRS) { + const a = providerNameFor(pair.providerA); + const b = providerNameFor(pair.providerB); + items.push({ + id: `compare:${pair.slug}`, + kind: "Compare", + title: `${a} vs ${b}`, + url: `/compare/${pair.slug}`, + description: truncate(`Head to head benchmark data for ${a} and ${b}.`), + tags: [pair.providerA, pair.providerB, pair.slug, "vs", "versus"], + }); + } + + // ── Static top-level pages ───────────────────────────────────── + for (const page of STATIC_PAGES) { + items.push({ + id: `page:${page.url}`, + kind: "Page", + title: page.title, + url: page.url, + description: truncate(page.description), + tags: page.tags, + }); + } + + return items; +}); diff --git a/src/lib/search/types.ts b/src/lib/search/types.ts new file mode 100644 index 00000000..840a720d --- /dev/null +++ b/src/lib/search/types.ts @@ -0,0 +1,32 @@ +/** + * Shape of a single searchable document indexed at build time. + * + * One flat list is built once on the server (via `buildIndex.ts`) and + * shipped to the client as JSON. Fuse.js then runs in the browser on + * the cached list. Keep this type narrow: every extra field is sent + * over the wire and weighs on the cmdk dialog's first paint. + */ +export type SearchKind = + | "Benchmark" + | "Product" + | "Alternative" + | "Answer" + | "Chain" + | "Compare" + | "Page"; + +export type SearchItem = { + /** Stable cmdk value. Slug-based, unique across all kinds. */ + id: string; + /** Visible label rendered as the bold first line. */ + title: string; + /** Kind badge, also used by cmdk to group results. */ + kind: SearchKind; + /** Destination route (absolute, starts with `/`). */ + url: string; + /** One-line description, ~150 chars max. Truncated in the UI. */ + description?: string; + /** Extra haystack tokens for fuzzy matching (category, provider + * names, chain slugs). Never rendered. */ + tags?: string[]; +}; From 7db17b56e123e7486023dfafde8176d4f6edc964 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 24 Jun 2026 14:08:58 +0200 Subject: [PATCH 2/2] fix(search): consume context inside dialog (Next.js 16 serializable props rule) --- src/components/search/search-dialog.tsx | 9 +++------ src/components/search/search-provider.tsx | 14 ++++++++++---- src/lib/search/buildIndex.ts | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/search/search-dialog.tsx b/src/components/search/search-dialog.tsx index d4a67413..28464364 100644 --- a/src/components/search/search-dialog.tsx +++ b/src/components/search/search-dialog.tsx @@ -5,13 +5,9 @@ import Fuse from "fuse.js"; import { Search, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useSearch } from "@/components/search/search-provider"; import type { SearchItem, SearchKind } from "@/lib/search/types"; -type Props = { - items: SearchItem[]; - onClose: () => void; -}; - const KIND_ORDER: SearchKind[] = [ "Benchmark", "Product", @@ -34,7 +30,8 @@ const POPULAR_BENCH_SLUGS = [ "rpc-capabilities", ]; -export default function SearchDialog({ items, onClose }: Props) { +export default function SearchDialog() { + const { items, close: onClose } = useSearch(); const router = useRouter(); const [query, setQuery] = useState(""); const inputRef = useRef(null); diff --git a/src/components/search/search-provider.tsx b/src/components/search/search-provider.tsx index cd9cfa4a..4a76df6f 100644 --- a/src/components/search/search-provider.tsx +++ b/src/components/search/search-provider.tsx @@ -6,6 +6,9 @@ import type { SearchItem } from "@/lib/search/types"; type Ctx = { open: () => void; + close: () => void; + isOpen: boolean; + items: SearchItem[]; }; const SearchCtx = createContext(null); @@ -31,7 +34,7 @@ export function SearchProvider({ items, children }: ProviderProps) { const close = useCallback(() => setIsOpen(false), []); // Global keyboard shortcuts. Cmd+K (mac) / Ctrl+K (win/linux) toggle - // the dialog. `/` opens it the way GitHub does — but only when the + // the dialog. `/` opens it the way GitHub does, but only when the // active element is not already an input, so typing `/` in a search // box on a bench page doesn't fire twice. useEffect(() => { @@ -54,12 +57,15 @@ export function SearchProvider({ items, children }: ProviderProps) { return () => window.removeEventListener("keydown", onKey); }, [isOpen]); - const value = useMemo(() => ({ open }), [open]); + const value = useMemo( + () => ({ open, close, isOpen, items }), + [open, close, isOpen, items], + ); return ( {children} - {isOpen && } + {isOpen && } ); } @@ -70,7 +76,7 @@ export function useSearch(): Ctx { // Soft fallback: when something tries to open the dialog outside // the provider (shouldn't happen, but cheap to guard) we make the // trigger inert rather than crash the page. - return { open: () => {} }; + return { open: () => {}, close: () => {}, isOpen: false, items: [] }; } return ctx; } diff --git a/src/lib/search/buildIndex.ts b/src/lib/search/buildIndex.ts index 85f0974f..bec9813a 100644 --- a/src/lib/search/buildIndex.ts +++ b/src/lib/search/buildIndex.ts @@ -6,7 +6,7 @@ * emits one flat `SearchItem[]` for the client-side Fuse.js instance. * * Build-safe by design: only reads YAML specs and TypeScript registries. - * Never touches Prometheus, KV, or any network — so calling this from + * Never touches Prometheus, KV, or any network, so calling this from * `app/layout.tsx` is cheap even on a cold build with no live data. */