diff --git a/.env.local.example b/.env.local.example index 9872e55dd..6166e9aa3 100644 --- a/.env.local.example +++ b/.env.local.example @@ -10,10 +10,5 @@ OPENAI_API_KEY= # Falls back to OPENAI_API_KEY if not set ANTHROPIC_API_KEY= -# Algolia search (search API key is public/read-only) -NEXT_PUBLIC_ALGOLIA_APP_ID= -NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY= -NEXT_PUBLIC_ALGOLIA_INDEX_NAME= - # Required for Contact Sales form submission (n8n → Attio pipeline) NEXT_PUBLIC_ATTIO_WEBHOOK_URL= diff --git a/.github/workflows/algolia-reindex.yml b/.github/workflows/algolia-reindex.yml deleted file mode 100644 index 6626f4d9a..000000000 --- a/.github/workflows/algolia-reindex.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Reindex Algolia after production deploy - -# Triggers a full Algolia Crawler reindex whenever a Vercel *production* -# deployment succeeds, so newly published docs pages appear in search right -# away instead of waiting for the crawler's scheduled run. -# -# Vercel's Git integration posts a GitHub deployment status of -# state=success / environment=Production once the deploy is live, which is -# what this workflow keys off of (preview and staging deploys are ignored). -# -# To avoid unnecessary crawls, the reindex is skipped when the deployed commit -# didn't touch docs content. `main` is squash-merged, so `git diff-tree HEAD` -# lists exactly the files that publish changed. -# -# Requires (repo Settings -> Secrets and variables -> Actions): -# - Variable ALGOLIA_CRAWLER_ID (fe95aa31-abbb-40ea-a31b-04a9ccd176b4) -# - Secret ALGOLIA_CRAWLER_USER_ID (from Algolia dashboard -> Crawler -> API credentials) -# - Secret ALGOLIA_CRAWLER_API_KEY (from Algolia dashboard -> Crawler -> API credentials) - -on: - deployment_status: - -# Serialize reindexes so a non-docs deploy doesn't cancel an in-flight crawl -# triggered by an earlier docs deploy. Queued runs each check their own commit; -# only runs whose commit touched docs content actually trigger a crawl. -concurrency: - group: algolia-reindex - cancel-in-progress: false - -permissions: - contents: read - -jobs: - reindex: - if: > - github.event.deployment_status.state == 'success' && - github.event.deployment_status.environment == 'Production' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.deployment.sha }} - fetch-depth: 2 - - - name: Check whether docs content changed - id: changed - run: | - files=$(git diff-tree --no-commit-id --name-only -r HEAD) - if echo "$files" | grep -qE '^content/docs/|^toolkit-docs-generator/data/toolkits/|^components/toolkit-docs/|^lib/toolkit-|^algolia/crawler-config\.js$|^scripts/sync-crawler-config\.ts$'; then - echo "docs=true" >> "$GITHUB_OUTPUT" - echo "Docs content changed — will reindex." - else - echo "docs=false" >> "$GITHUB_OUTPUT" - echo "No docs content changed — skipping reindex." - fi - - - name: Trigger Algolia Crawler reindex - if: steps.changed.outputs.docs == 'true' - env: - CRAWLER_ID: ${{ vars.ALGOLIA_CRAWLER_ID }} - CRAWLER_USER_ID: ${{ secrets.ALGOLIA_CRAWLER_USER_ID }} - CRAWLER_API_KEY: ${{ secrets.ALGOLIA_CRAWLER_API_KEY }} - run: | - echo "Triggering reindex for crawler ${CRAWLER_ID}" - curl -sS --fail-with-body -X POST \ - "https://crawler.algolia.com/api/1/crawlers/${CRAWLER_ID}/reindex" \ - -u "${CRAWLER_USER_ID}:${CRAWLER_API_KEY}" diff --git a/.github/workflows/sync-crawler-config.yml b/.github/workflows/sync-crawler-config.yml deleted file mode 100644 index 2f717f567..000000000 --- a/.github/workflows/sync-crawler-config.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Sync Algolia crawler config - -# Pushes algolia/crawler-config.js to the Algolia Crawler and triggers a -# reindex, so the versioned config is the source of truth and no manual -# dashboard editing is needed. -# -# Runs only when the config (or this sync script) changes on main, plus a -# manual trigger. Content-only publishes are handled by algolia-reindex.yml -# instead, so the two never double-crawl. -# -# Requires (repo Settings -> Secrets and variables -> Actions): -# - Variable ALGOLIA_CRAWLER_ID -# - Secret ALGOLIA_CRAWLER_USER_ID -# - Secret ALGOLIA_CRAWLER_API_KEY -# - Secret ALGOLIA_ADMIN_API_KEY (optional — needed to push -# initialIndexSettings to existing indices, since the crawler -# only applies them on index creation) - -on: - push: - branches: [main] - paths: - - algolia/crawler-config.js - - scripts/sync-crawler-config.ts - workflow_dispatch: - -concurrency: - group: algolia-sync-config - cancel-in-progress: true - -permissions: - contents: read - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile - - name: Sync config and trigger reindex - env: - ALGOLIA_CRAWLER_ID: ${{ vars.ALGOLIA_CRAWLER_ID }} - ALGOLIA_CRAWLER_USER_ID: ${{ secrets.ALGOLIA_CRAWLER_USER_ID }} - ALGOLIA_CRAWLER_API_KEY: ${{ secrets.ALGOLIA_CRAWLER_API_KEY }} - ALGOLIA_ADMIN_API_KEY: ${{ secrets.ALGOLIA_ADMIN_API_KEY }} - # On push, skip the reindex here — algolia-reindex.yml fires after the - # Vercel production deploy so pages are live before the crawl starts. - # On workflow_dispatch there is no pending deploy, so reindex immediately. - SKIP_REINDEX: ${{ github.event_name == 'push' }} - run: bun run sync-crawler-config diff --git a/CLAUDE.md b/CLAUDE.md index 472788c9f..774b374bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Run a script directly with `bun scripts/.ts` (never `tsx`, `npx`, `pnpm`, - **`lib/`** — fumadocs source/layout config, toolkit data/slug/static-param helpers, integration catalog, glossary remark plugin, `remark-nextra-compat`, config and dictionaries. - **`data/partner-toolkits.ts`** — Hand-authored partner integrations (no toolkit JSON). - **`toolkit-docs-generator/`** — Generates MCP toolkit JSON into `toolkit-docs-generator/data/toolkits/`; `scripts/sync-toolkit-sidebar.ts` emits the nine integration category `meta.json` files. -- **`scripts/`** — Build/CI scripts (Vale style fixes/reviews, redirect checking, meta.json validation, internal link updates, llms.txt generation, Algolia crawler config sync). +- **`scripts/`** — Build/CI scripts (Vale style fixes/reviews, redirect checking, meta.json validation, internal link updates, llms.txt generation). - **`tests/`** — Vitest tests (broken links, internal links, frontmatter, sitemap, toolkit data, navigation contracts). `tests/helpers/content-paths.ts` maps content files ↔ URLs. - **`redirects.ts`** — Redirect data module (~215 rules) imported by `next.config.ts`, the redirect checker, and tests. - **`source.config.ts`** — fumadocs-mdx config (frontmatter schema, Shiki transformers). `proxy.ts` redirects locale-less and non-English paths to `/en`. diff --git a/algolia/README.md b/algolia/README.md deleted file mode 100644 index 7b73fd49d..000000000 --- a/algolia/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Docs search (Algolia) - -Search on `docs.arcade.dev` is powered by an Algolia Crawler that indexes the -live site. This directory holds the crawler configuration as the **source of -truth**, so search behavior is versioned, reviewed, and changed through git. - -## Edit the config here, never in the Algolia dashboard - -`crawler-config.js` is the real configuration. Treat the Algolia dashboard -editor as generated output, not a place to make changes. - -> **Do not edit the crawler config in the Algolia UI.** Dashboard edits are not -> tracked in git, get overwritten by the next sync, and cause the live crawler -> to drift from this file. Change `crawler-config.js` and open a PR instead. - -The dashboard is still useful for **read-only** work: the URL Tester (to preview -what a config change would extract) and Monitoring (to see crawl status and -which URLs succeeded or were ignored). - -## How search stays up to date - -Two GitHub Actions workflows keep the index fresh. They never overlap. - -| When | Workflow | What happens | -| --- | --- | --- | -| Docs content is published (production deploy) | `algolia-reindex.yml` | Triggers a reindex, but only when the deploy touched `content/docs/**` or `toolkit-docs-generator/data/toolkits/**` | -| `crawler-config.js` changes on `main` | `sync-crawler-config.yml` | Pushes the config to the Crawler API, then triggers a reindex | - -The crawler also runs on a weekly schedule as a backstop. - -## Change search behavior - -1. Edit `crawler-config.js` (extraction selectors, ranking, searchable - attributes, crawl scope, and so on). -2. Optional but recommended: paste the change into the dashboard URL Tester on a - representative page to confirm it extracts what you expect. -3. Open a PR. On merge, `sync-crawler-config.yml` applies it and reindexes. - -To apply the config without a merge, run the sync workflow manually from the -Actions tab (`workflow_dispatch`), or run `bun run sync-crawler-config` locally with -the crawler credentials set as environment variables. - -## Configuration and secrets - -The crawler API key is never stored in this repo. It lives in the Algolia -dashboard and in CI secrets. The workflows need: - -| Name | Type | Source | -| --- | --- | --- | -| `ALGOLIA_CRAWLER_ID` | Variable | Crawler dashboard URL | -| `ALGOLIA_CRAWLER_USER_ID` | Secret | Crawler → API credentials | -| `ALGOLIA_CRAWLER_API_KEY` | Secret | Crawler → API credentials | -| `ALGOLIA_ADMIN_API_KEY` | Secret | Algolia dashboard → API Keys → Admin API Key | - -### Why `ALGOLIA_ADMIN_API_KEY` is needed - -The crawler's `initialIndexSettings` block is only applied when the crawler -*creates* an index. Once the production index exists, later edits to that -block (for example, tuning `removeWordsIfNoResults` to relax matching for -long agent-style queries) never reach the live index through the crawler -API alone, and the no-result rate stays elevated for those queries. - -`scripts/sync-crawler-config.ts` therefore also pushes `initialIndexSettings` -to each live index via the Search API's `PUT /1/indexes/{name}/settings` -using this key. Without the key the config still syncs and a reindex still -runs, but the index-settings step is skipped with a warning. - -The public search credentials used by the frontend widget -(`components/algolia-search.tsx`) are separate `NEXT_PUBLIC_ALGOLIA_*` -environment variables. - -## Troubleshooting - -- **A crawl is blocked with `SafeReindexingError`.** The new crawl produced far - fewer records than the live index (a safety guard against a broken extractor). - Check Monitoring and the URL Tester to confirm the extraction is correct. If - the smaller result is legitimate, use "Replace production index" in the - dashboard to accept the new baseline. If it is a regression, fix the selectors - in `crawler-config.js` first. -- **A new page is missing from search.** Confirm the production deploy touched a - watched path (otherwise `algolia-reindex.yml` skips the crawl), and check that - the page appears in `sitemap.xml`. diff --git a/algolia/crawler-config.js b/algolia/crawler-config.js deleted file mode 100644 index ed1a6b98c..000000000 --- a/algolia/crawler-config.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Algolia Crawler configuration — source of truth for the docs search index. - * - * The live crawler config is edited in the Algolia dashboard - * (Data Sources -> Crawler -> Editor). This file mirrors it so changes are - * reviewable in git instead of only living in the dashboard. - * - * To apply a change to the live crawler, edit this file — merging it to main - * runs `.github/workflows/sync-crawler-config.yml`, which pushes it via the - * Crawler API and triggers a reindex (`bun run sync-crawler-config`). To apply it - * by hand instead, wrap the object below in `new Crawler({ ...crawlerConfig, - * apiKey })` in the dashboard editor and run a crawl. - * - * The crawler API key is intentionally NOT stored here — it lives only in the - * Algolia dashboard and in CI secrets (see .github/workflows/algolia-reindex.yml). - * - * App ID: BJB8PBSQ9T - * Index: docs_arcade_dev_bjb8pbsq9t_docsearch - * Crawler ID: fe95aa31-abbb-40ea-a31b-04a9ccd176b4 - */ -export const crawlerConfig = { - appId: "BJB8PBSQ9T", - indexPrefix: "", - rateLimit: 8, - maxUrls: null, - // Weekly backstop only — freshness is driven by reindex-on-publish - // (.github/workflows/algolia-reindex.yml). - schedule: "every 1 week", - startUrls: ["https://docs.arcade.dev"], - sitemaps: ["https://docs.arcade.dev/sitemap.xml"], - saveBackup: false, - ignoreQueryParams: ["source", "utm_*"], - // llms.txt is markdown ("- [title](url): description" lines). Crawling it - // makes the crawler mis-parse those lines into bogus URLs that 404, so keep - // it out of the crawl — it's a machine-readable export, not a page. - exclusionPatterns: ["**/cdn-cgi/**", "**/llms.txt", "**/llms-full.txt"], - actions: [ - { - indexName: "docs_arcade_dev_bjb8pbsq9t_docsearch", - pathsToMatch: ["https://docs.arcade.dev/**"], - recordExtractor: ({ $, helpers }) => { - // Strip site chrome so nav/sidebar/toc are not indexed as content. - $( - "nav, header, footer, aside, .nextra-navbar, .nextra-sidebar, .nextra-toc, .nextra-banner, .nextra-mobile-nav, .nextra-skip-nav" - ).remove(); - - return helpers.docsearch({ - recordProps: { - lvl0: { selectors: "article h1", defaultValue: "Arcade Docs" }, - lvl1: "article h2", - lvl2: "article h3", - lvl3: "article h4", - lvl4: "article h5", - lvl5: "article h6", - // Include `pre` so terms that only appear in code samples - // (e.g. `arcade_api_key`, `tool_choice`, `chat.completions`) are - // searchable — several no-result queries were code-only terms. - content: "article p, article li, article td, article pre", - }, - indexHeadings: true, - aggregateContent: true, - }); - }, - }, - ], - // The crawler applies `initialIndexSettings` only when it *creates* an - // index, so edits below never reach an existing production index by - // themselves. `scripts/sync-crawler-config.ts` also pushes these via the - // Search API when ALGOLIA_ADMIN_API_KEY is set; see algolia/README.md. - initialIndexSettings: { - docs_arcade_dev_bjb8pbsq9t_docsearch: { - distinct: true, - attributeForDistinct: "url_without_anchor", - // Long, keyword-heavy queries (often from agents/LLMs) used to return - // nothing because Algolia's default `removeWordsIfNoResults: "none"` - // requires every word to match a single record. Relax to "allOptional" - // so a query still surfaces its best partial matches instead of zero. - removeWordsIfNoResults: "allOptional", - ignorePlurals: true, - searchableAttributes: [ - "unordered(hierarchy.lvl0)", - "unordered(hierarchy.lvl1)", - "unordered(hierarchy.lvl2)", - "unordered(hierarchy.lvl3)", - "unordered(hierarchy.lvl4)", - "unordered(hierarchy.lvl5)", - "content", - ], - customRanking: ["asc(anchor)"], - attributesToRetrieve: [ - "hierarchy", - "content", - "anchor", - "url", - "url_without_anchor", - "type", - ], - attributesForFaceting: ["type", "lang"], - }, - }, -}; diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 000000000..2364a27b0 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,33 @@ +import { builtinSearch } from "@/lib/builtin-search"; + +// Results depend only on the query string and the deployed content, so a CDN +// can cache them. Vercel keys its cache per deployment, so a new deploy never +// serves results from an older index. +const CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400"; + +// Real queries are short, but people paste error messages and ask full +// questions, so allow plenty. Anything longer is cut rather than rejected: +// the extra words add little signal, and the dialog never shows an error. +// Cutting also keeps one cache entry per query instead of one per paste. +const MAX_QUERY_LENGTH = 200; + +function limitQueryLength(request: Request): Request { + const url = new URL(request.url); + const query = url.searchParams.get("query"); + if (query === null || query.length <= MAX_QUERY_LENGTH) { + return request; + } + url.searchParams.set("query", query.slice(0, MAX_QUERY_LENGTH).trimEnd()); + return new Request(url, request); +} + +export async function GET(request: Request) { + const response = await builtinSearch.GET(limitQueryLength(request)); + const headers = new Headers(response.headers); + headers.set("Cache-Control", CACHE_CONTROL); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} diff --git a/app/global.css b/app/global.css index a722083f6..3c50814f2 100644 --- a/app/global.css +++ b/app/global.css @@ -292,7 +292,7 @@ margin-inline-end: 3.5rem !important; } -.nextra-navbar nav > div:has(.algolia-search-button) { +.nextra-navbar nav > div:has(.search-button) { order: -1; margin-inline: 1rem auto; } @@ -418,9 +418,7 @@ html.dark[data-page-kind="toolkit"] [data-callout][data-type="idea"] { border-color: rgb(34 197 94 / 0.4) !important; } -/* Algolia search hit highlight — brand color */ -.ais-Highlight-highlighted, -.ais-Snippet-highlighted, +/* Search hit highlight: brand color */ [data-search-dialog] mark { font-style: normal; font-weight: 600; @@ -429,8 +427,6 @@ html.dark[data-page-kind="toolkit"] [data-callout][data-type="idea"] { border-radius: 2px; } -.dark .ais-Highlight-highlighted, -.dark .ais-Snippet-highlighted, .dark [data-search-dialog] mark { background: color-mix(in oklch, var(--primary) 20%, transparent); } diff --git a/bun.lock b/bun.lock index 709796808..96a99b309 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,6 @@ "@ory/client": "1.22.37", "@shikijs/transformers": "4.5.0", "@uidotdev/usehooks": "2.4.1", - "algoliasearch": "5.53.0", "fumadocs-core": "16.15.18", "fumadocs-mdx": "15.4.6", "fumadocs-ui": "16.15.18", @@ -27,7 +26,6 @@ "react": "19.2.7", "react-dom": "19.2.7", "react-hook-form": "7.77.0", - "react-instantsearch": "7.34.0", "react-markdown": "10.1.0", "react-syntax-highlighter": "16.1.1", "remark-gfm": "4.0.1", @@ -102,36 +100,6 @@ "@ai-sdk/react": ["@ai-sdk/react@4.0.130", "", { "dependencies": { "@ai-sdk/mcp": "2.0.66", "@ai-sdk/provider": "4.0.21", "@ai-sdk/provider-utils": "5.0.53", "ai": "7.0.127", "swr": "^2.4.1", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-6nUGT2mWIPUK/s2nUJH0Hiu90Oqawwo2INAHekOi4hJBb8zhFJS0yMP0aRn/+UO9VmJT/v58d8rWSmGE3B6ApA=="], - "@algolia/abtesting": ["@algolia/abtesting@1.19.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-Lhnez3hhXHk25lfxLAMxvkP4fmN3+1RgADhD2ssMDBYuAsDVReeyP+3SGRx+ntq8ijMrLqUyfvO72TB6jsTteQ=="], - - "@algolia/client-abtesting": ["@algolia/client-abtesting@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-0ZjA5Hcmaoz5Lj6OG0zhfIyeqzJZnLW2CRJA1W17UwMFGRtZAJ9yJKRvPEDA6gkpsIoQxORTSW6sWFiuYncPNQ=="], - - "@algolia/client-analytics": ["@algolia/client-analytics@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-kWNodP75iiEaOtemC9F/hlxNBG5E2QUjN1BusnE6m2b4l7Qh/BUO3fGCVsmKJI65VO4VKGGmT43ICvHtTcJ2JQ=="], - - "@algolia/client-common": ["@algolia/client-common@5.53.0", "", {}, "sha512-YPN45TXD9Wrse185t/Ta7nktZsqpv97oOjCzp2sblHnCL6rBc9TDeJAg1IGl2UpdwnSD05Zu/5wLB4watOUMyg=="], - - "@algolia/client-insights": ["@algolia/client-insights@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-qAcYTDJE6m924FDDUQvdD6vh7DYaqOeSpFS74IP37/JRV0v4cGBauyxTF2WzDnokUylQDbqreoFIJZfg0Fitmw=="], - - "@algolia/client-personalization": ["@algolia/client-personalization@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-fQaY+DkSJOpuUVUe8MQTwrdiKAqkJGhpDarB08duBn/sUv7Bkib6MDRQauCcWTWTe4HIW+EbwQP9R4kci1V/Yw=="], - - "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-o72tsiEZGfeS/dxL9IADfzcZWGEwKDEe5CvtrBuT//3JR+SHuTtHRI2ZTf7D7bcKagcbojvO8hnkHdfoakSlYg=="], - - "@algolia/client-search": ["@algolia/client-search@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-Ds16IyPm/dNJPCU8OzApo2gwGrgWT5BYHhE3NFwZbpCveqyvPDB9sZDDkJ5DsdOGT2aC+R3i0/M1OVXF2qdgPg=="], - - "@algolia/events": ["@algolia/events@4.0.1", "", {}, "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ=="], - - "@algolia/ingestion": ["@algolia/ingestion@1.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-oNbT6z4NwD8Pou9VPINGlN/tlG1afESh2EbxqnP6rwl95xKVD/Zlciis1PpNeO/9U/rrajc1+7DcfKi03tX1KQ=="], - - "@algolia/monitoring": ["@algolia/monitoring@1.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-G+KZb/yd+qAOFn/cEvTGeLxQm8aP3a0od50l3z/ylccY+/o4YG3TNcjU1tFQHW4mXC137GPyR7W70R0kRQDLnA=="], - - "@algolia/recommend": ["@algolia/recommend@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-6aVfYd55Un6IUgPLbo84WfgFZlS3L0vA1ttzXL5vahHewUJ8jYgd89TzlWRTeej7w70mb9RWsVlFYGmJ/diQww=="], - - "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0" } }, "sha512-ke27DqgzCOlt+RbeEdCxtXxMQOnAOi8ujr2wid0DmDKzR95Kw/f9sBsuhBxtjevCqJRJszfRTLY0B1pbO6IhkA=="], - - "@algolia/requester-fetch": ["@algolia/requester-fetch@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0" } }, "sha512-GngiOqt2Gq4oLno6yXQVj9om+qSO9SWAoduoTOEg79dKZ62brB8OOIvSJG/vDNoanYi6a7Al9uDZwXvi+bcVTg=="], - - "@algolia/requester-node-http": ["@algolia/requester-node-http@5.53.0", "", { "dependencies": { "@algolia/client-common": "5.53.0" } }, "sha512-6mF9LZMUk0QqWvrnxkxBqhswwz6Xfiwy6/gmTzL5HrlhdVG3ITAqGV2k3XmVThP1h0Ulc3VQwiNCD7/Nr4JNlQ=="], - "@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="], "@antfu/install-pkg": ["@antfu/install-pkg@2.1.0", "", { "dependencies": { "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.1" } }, "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw=="], @@ -804,20 +772,14 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/dom-speech-recognition": ["@types/dom-speech-recognition@0.0.1", "", {}, "sha512-udCxb8DvjcDKfk1WTBzDsxFbLgYxmQGKrE/ricoMqHRNjSlSUCcamVTA5lIQqzY10mY5qCY0QDwBfFEwhfoDPw=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], - "@types/google.maps": ["@types/google.maps@3.66.6", "", {}, "sha512-F+m8NwLXXDf43/GsxmyceugqpBtKMaQGM/raqyINTG+r7fFgCXKxTZJ6Aw4QeMwUkIJwDtYVeUhndq3RFNkuDA=="], - "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], - "@types/hogan.js": ["@types/hogan.js@3.0.5", "", {}, "sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA=="], - "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], @@ -828,8 +790,6 @@ "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], - "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], - "@types/ramda": ["@types/ramda@0.30.2", "", { "dependencies": { "types-ramda": "^0.30.1" } }, "sha512-PyzHvjCalm2BRYjAU6nIB3TprYwMNOUY/7P/N8bSzp9W/yM2YrtGtAnnVtaCNSeOZ8DzKyFDvaqQs7LnWwwmBA=="], "@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="], @@ -894,8 +854,6 @@ "@yuku-toolchain/types": ["@yuku-toolchain/types@0.10.2", "", {}, "sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q=="], - "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], - "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -904,10 +862,6 @@ "ai": ["ai@7.0.127", "", { "dependencies": { "@ai-sdk/gateway": "4.0.103", "@ai-sdk/provider": "4.0.21", "@ai-sdk/provider-utils": "5.0.53" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JNsNPk4ZvRGEJuqdVo2lYFkm7uAuf3p/RwKFWn1Wh/+IK3U1RyFWw2GXoFnhzqNfxEuj7XJB4WpIUhl1sHToWg=="], - "algoliasearch": ["algoliasearch@5.53.0", "", { "dependencies": { "@algolia/abtesting": "1.19.0", "@algolia/client-abtesting": "5.53.0", "@algolia/client-analytics": "5.53.0", "@algolia/client-common": "5.53.0", "@algolia/client-insights": "5.53.0", "@algolia/client-personalization": "5.53.0", "@algolia/client-query-suggestions": "5.53.0", "@algolia/client-search": "5.53.0", "@algolia/ingestion": "1.53.0", "@algolia/monitoring": "1.53.0", "@algolia/recommend": "5.53.0", "@algolia/requester-browser-xhr": "5.53.0", "@algolia/requester-fetch": "5.53.0", "@algolia/requester-node-http": "5.53.0" } }, "sha512-OGW1q6b91CRSSeiOnM8LxuR5NYJ2esvw66jUZ4IIvdv+ItNkx3pwLuyR+jaCdbGee4ov5WgUnyPryyh11xvByQ=="], - - "algoliasearch-helper": ["algoliasearch-helper@3.29.1", "", { "dependencies": { "@algolia/events": "^4.0.1" }, "peerDependencies": { "algoliasearch": ">= 3.1 < 6" } }, "sha512-6ck2YFudF2Pje7szQoPBiRFTGfd+1I+0I/WfLPGn0bj1kvrFoOQmNyedNiDxTk3/r4IfSLDYk+RA4G7u8H6+yA=="], - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], @@ -1274,10 +1228,6 @@ "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], - "hogan.js": ["hogan.js@3.0.2", "", { "dependencies": { "mkdirp": "0.3.0", "nopt": "1.0.10" }, "bin": { "hulk": "./bin/hulk" } }, "sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg=="], - - "htm": ["htm@3.1.1", "", {}, "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ=="], - "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], @@ -1300,10 +1250,6 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "instantsearch-ui-components": ["instantsearch-ui-components@0.29.0", "", { "dependencies": { "@swc/helpers": "0.5.18", "markdown-to-jsx": "^7.7.15" } }, "sha512-/7bY63BM+nRs38vL/1hYtjheDlf3FEK2edsnc3UEA3JJZZZk0Bq1Ds0o8WcSVymT/BSZz8DNb87eLDTz6gDVcw=="], - - "instantsearch.js": ["instantsearch.js@4.100.0", "", { "dependencies": { "@algolia/events": "^4.0.1", "@swc/helpers": "0.5.18", "@types/dom-speech-recognition": "^0.0.1", "@types/google.maps": "^3.55.12", "@types/hogan.js": "^3.0.0", "@types/qs": "^6.5.3", "algoliasearch-helper": "3.29.1", "hogan.js": "^3.0.2", "htm": "^3.0.0", "instantsearch-ui-components": "0.29.0", "preact": "^10.10.0", "qs": "^6.5.1", "react": ">= 0.14.0", "search-insights": "^2.17.2" }, "peerDependencies": { "algoliasearch": ">= 3.1 < 6" } }, "sha512-8fomIg5pwSTDDQQL/yUNWdc0lvvw+1xhjgNlWJwgmRsonZ3GlLT00bvznfrTHq0IRw9/Z894uMQEpH0t/l742A=="], - "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], @@ -1416,8 +1362,6 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "markdown-to-jsx": ["markdown-to-jsx@7.7.17", "", { "peerDependencies": { "react": ">= 0.14.0" }, "optionalPeers": ["react"] }, "sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ=="], - "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -1544,8 +1488,6 @@ "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "mkdirp": ["mkdirp@0.3.0", "", {}, "sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew=="], - "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], "motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], @@ -1570,16 +1512,12 @@ "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], - "nopt": ["nopt@1.0.10", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "./bin/nopt.js" } }, "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg=="], - "npm-to-yarn": ["npm-to-yarn@3.2.0", "", {}, "sha512-K1HmQeZT2HrjpsR6KgqbN2FAXL2NrJJmNUSD9ck7HGTVu1JKXox8n9SB+tjbU8m8JGLF4OscrroPepew/L7/Xw=="], "nypm": ["nypm@0.6.10", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.3.1" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -1640,8 +1578,6 @@ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], - "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], @@ -1674,10 +1610,6 @@ "react-inspector": ["react-inspector@6.0.2", "", { "peerDependencies": { "react": "^16.8.4 || ^17.0.0 || ^18.0.0" } }, "sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ=="], - "react-instantsearch": ["react-instantsearch@7.34.0", "", { "dependencies": { "@swc/helpers": "0.5.18", "instantsearch-ui-components": "0.29.0", "instantsearch.js": "4.100.0", "react-instantsearch-core": "7.34.0" }, "peerDependencies": { "algoliasearch": ">= 3.1 < 6", "react": ">= 16.8.0 < 20", "react-dom": ">= 16.8.0 < 20" } }, "sha512-qQ2tKQUyxluyb9ACjXXKiLzAnlcyELPuLMmkF/gCQXe3Sr7SKKhoabW+oEQHTNsBQBeOKRnxKGiLbECZ+VyKzw=="], - - "react-instantsearch-core": ["react-instantsearch-core@7.34.0", "", { "dependencies": { "@swc/helpers": "0.5.18", "algoliasearch-helper": "3.29.1", "instantsearch.js": "4.100.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "algoliasearch": ">= 3.1 < 6", "react": ">= 16.8.0 < 20" } }, "sha512-TnQdiIR44hHn/3Ef187Bu/bnTKxwk/Wi6zyDgft0pcXfjjGu4/7Y8X1FA27akXU20XzK3R+E2uizjOB50EBdRQ=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], @@ -1782,8 +1714,6 @@ "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], - "search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="], - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="], @@ -1806,14 +1736,6 @@ "short-unique-id": ["short-unique-id@5.3.2", "", { "bin": { "short-unique-id": "bin/short-unique-id", "suid": "bin/short-unique-id" } }, "sha512-KRT/hufMSxXKEDSQujfVE0Faa/kZ51ihUcZQAcmP04t00DvPj7Ox5anHke1sJYUtzSuiT/Y5uyzg/W7bBEGhCg=="], - "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], - - "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -2056,10 +1978,6 @@ "fumadocs-ui/motion": ["motion@13.5.1", "", { "dependencies": { "framer-motion": "13.5.1", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-eV9SUem6KnzEa9+eT+dVBeDlhy5TTdn09YWR1WhUsKoZCJPw/n4TcYhjbxq2Gy5PTwDr//4H3r5vxkcZnNnO1w=="], - "instantsearch-ui-components/@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="], - - "instantsearch.js/@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="], - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], @@ -2068,10 +1986,6 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "react-instantsearch/@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="], - - "react-instantsearch-core/@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="], - "recharts/reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], "remarkable/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], diff --git a/components/algolia-search.tsx b/components/algolia-search.tsx deleted file mode 100644 index f808c2e8d..000000000 --- a/components/algolia-search.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; - -import { liteClient as algoliasearch } from "algoliasearch/lite"; -import { Search } from "lucide-react"; -import { useEffect, useState } from "react"; -import { - Configure, - Highlight, - Hits, - InstantSearch, - SearchBox, - Snippet, - useInstantSearch, -} from "react-instantsearch"; - -type DocSearchHierarchy = { - lvl0: string | null; - lvl1: string | null; - lvl2: string | null; - lvl3: string | null; - lvl4: string | null; - lvl5: string | null; -}; - -type DocSearchRecord = { - objectID: string; - type?: "lvl0" | "lvl1" | "lvl2" | "lvl3" | "lvl4" | "lvl5" | "content"; - hierarchy: DocSearchHierarchy; - content: string | null; - url: string; - anchor: string | null; - // Legacy flat fields from non-docsearch indexes - title?: string; - description?: string; -}; - -const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID; -const searchKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY; -const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME; - -const searchClient = - appId && searchKey ? algoliasearch(appId, searchKey) : null; - -function safeHref(url: string | undefined): string { - if (!url) { - return "/"; - } - if ( - url.startsWith("https://") || - (url.startsWith("/") && !url.startsWith("//")) - ) { - return url; - } - return "/"; -} - -function getHitUrl(hit: DocSearchRecord): string { - // DocSearch records include full URLs; make them relative for same-site nav - try { - const parsed = new URL(hit.url); - return safeHref(parsed.pathname + parsed.hash); - } catch { - return safeHref(hit.url); - } -} - -function SectionPath({ hit }: { hit: DocSearchRecord }) { - if (!hit.hierarchy) { - return null; - } - - const castHit = hit as unknown as Parameters[0]["hit"]; - const levelKeys = ["lvl1", "lvl2", "lvl3", "lvl4", "lvl5"] as const; - const sectionLevels = levelKeys.filter((key) => hit.hierarchy?.[key]); - - if (sectionLevels.length === 0) { - return null; - } - - return ( -
- {sectionLevels.map((key, i) => ( - - {i > 0 && ( - - )} - - - - - ))} -
- ); -} - -function HitTitle({ hit }: { hit: DocSearchRecord }) { - const castHit = hit as unknown as Parameters[0]["hit"]; - - // lvl0 is the page title in DocSearch hierarchy - if (hit.hierarchy?.lvl0) { - return ; - } - - // Fallback for legacy flat records - if (hit.title) { - return ; - } - - return Untitled; -} - -function SearchHit({ hit }: { hit: DocSearchRecord }) { - const castHit = hit as unknown as Parameters[0]["hit"]; - const isContentHit = hit.type === "content"; - - return ( - -
- -
- - {isContentHit && hit.content && ( -
- -
- )} - {!isContentHit && hit.description && ( -
- -
- )} -
- ); -} - -function EmptyQuery() { - const { indexUiState } = useInstantSearch(); - if (indexUiState.query) { - return null; - } - return ( -

- Start typing to search the docs… -

- ); -} - -function NoResults() { - const { results } = useInstantSearch(); - if (!results?.query || results.nbHits > 0) { - return null; - } - return ( -

- No results for{" "} - "{results.query}" -

- ); -} - -function SearchUnavailable() { - return ( -

- Add NEXT_PUBLIC_ALGOLIA_APP_ID,{" "} - NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY, and{" "} - NEXT_PUBLIC_ALGOLIA_INDEX_NAME to your - environment to enable search. -

- ); -} - -export function AlgoliaSearch() { - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - setIsOpen((prev) => !prev); - } - if (e.key === "Escape") { - setIsOpen(false); - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, []); - - return ( - <> - - - {isOpen && ( -
-
- )} - - ); -} diff --git a/components/chrome/navbar.tsx b/components/chrome/navbar.tsx index 4d0c55be2..44e14a6b2 100644 --- a/components/chrome/navbar.tsx +++ b/components/chrome/navbar.tsx @@ -5,10 +5,10 @@ import { cn } from "@arcadeai/design-system/lib/utils"; import { useSidebar } from "fumadocs-ui/components/sidebar/base"; import Link from "next/link"; import { useCallback } from "react"; -import { AlgoliaSearch } from "@/components/algolia-search"; import { MenuIcon } from "@/components/chrome/icons"; import { Logo } from "@/components/logo"; import { NavLinks } from "@/components/nav-links"; +import { SearchButton } from "@/components/search-button"; import { DISCORD_URL, GITHUB_REPO_URL, LOGO_URL } from "@/lib/layout.shared"; /** @@ -52,7 +52,7 @@ export function Navbar() {
- +
- +
import("./search-dialog")); /** * Fumadocs root provider with the site defaults: dark theme by default and no - * theme hotkey. Fumadocs' own search is off; the docs search lives in - * components/algolia-search.tsx and handles its own shortcut. + * theme hotkey. Search uses a Fumadocs search dialog (Cmd/Ctrl + K, see + * components/search-dialog.tsx) backed by the /api/search route in + * lib/builtin-search.ts. */ export function AppRootProvider({ children }: { children: ReactNode }) { return ( {children} diff --git a/components/search-button.tsx b/components/search-button.tsx new file mode 100644 index 000000000..3766c5044 --- /dev/null +++ b/components/search-button.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useSearchContext } from "fumadocs-ui/contexts/search"; +import { Search } from "lucide-react"; + +/** + * Search trigger for the navbar and mobile menu. It opens Fumadocs' search + * dialog, which also handles the shortcut. The hint comes from Fumadocs' + * hotkey config, so it reads "⌘ K" on Mac and "Ctrl K" elsewhere. + */ +export function SearchButton() { + const { hotKey, setOpenSearch } = useSearchContext(); + + return ( + + ); +} diff --git a/components/search-dialog.tsx b/components/search-dialog.tsx new file mode 100644 index 000000000..7420c28da --- /dev/null +++ b/components/search-dialog.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useDocsSearch } from "fumadocs-core/search/client"; +import { fetchClient } from "fumadocs-core/search/client/fetch"; +import { + SearchDialog, + SearchDialogClose, + SearchDialogContent, + SearchDialogHeader, + SearchDialogIcon, + SearchDialogInput, + SearchDialogList, + SearchDialogOverlay, + type SearchItemType, +} from "fumadocs-ui/components/dialog/search"; +import type { SearchLink, SharedProps } from "fumadocs-ui/contexts/search"; +import posthog from "posthog-js"; +import { useEffect, useMemo, useRef, useState } from "react"; + +// A query counts as "searched" once the user has paused this long on it, so +// typing "gmail" logs one event rather than one per keystroke. +const SETTLE_MS = 1000; +const MIN_TRACKED_QUERY_LENGTH = 2; +const MAX_TRACKED_QUERY_LENGTH = 200; + +type DocsSearchDialogProps = SharedProps & { links?: SearchLink[] }; + +/** + * Fumadocs' default search dialog (fumadocs-ui/components/dialog/search-default) + * with PostHog events added, as the Fumadocs docs suggest for customizing it: + * + * - "Docs search": a query the user paused on, with how many results it got. + * Queries with no results show where the docs or the index have gaps. + * - "Docs search result selected": which page the user opened for that query. + * + * `links` are the suggestions shown while the input is empty. + */ +export default function DocsSearchDialog({ + links = [], + onOpenChange, + ...props +}: DocsSearchDialogProps) { + // Result count per query. Keyed by query so a slow earlier request finishing + // after a newer one can't hide the newer query's count. + const [resultCounts, setResultCounts] = useState>({}); + const lastTracked = useRef(""); + + // Wrap the fetch client to learn how many results each query returned. + const client = useMemo(() => { + const base = fetchClient(); + return { + deps: base.deps, + async search(text: string) { + const results = await base.search(text); + setResultCounts((counts) => ({ + ...counts, + [text.trim()]: results.length, + })); + return results; + }, + }; + }, []); + + const { search, setSearch, query } = useDocsSearch({ client }); + + const defaultItems = useMemo( + () => + links.length === 0 + ? null + : links.map(([name, url]) => ({ + type: "page" as const, + id: name, + content: name, + url, + })), + [links] + ); + + useEffect(() => { + const term = search.trim(); + if (term.length < MIN_TRACKED_QUERY_LENGTH) { + return; + } + const count = resultCounts[term]; + if (count === undefined) { + return; + } + const timer = setTimeout(() => { + if (lastTracked.current === term) { + return; + } + lastTracked.current = term; + posthog.capture("Docs search", { + query: term.slice(0, MAX_TRACKED_QUERY_LENGTH), + result_count: count, + has_results: count > 0, + }); + }, SETTLE_MS); + return () => clearTimeout(timer); + }, [search, resultCounts]); + + const handleSelect = (item: SearchItemType) => { + if (item.type === "action") { + return; + } + posthog.capture("Docs search result selected", { + query: search.trim().slice(0, MAX_TRACKED_QUERY_LENGTH), + url: item.url, + }); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + lastTracked.current = ""; + setResultCounts({}); + } + onOpenChange(open); + }; + + return ( + + + + + + + + + + + + ); +} diff --git a/lib/builtin-search.ts b/lib/builtin-search.ts new file mode 100644 index 000000000..e63e0f4ac --- /dev/null +++ b/lib/builtin-search.ts @@ -0,0 +1,73 @@ +import type { Index } from "fumadocs-core/search/server"; +import { createSearchAPI } from "fumadocs-core/search/server"; +import { resolveToolkitDataDir } from "@/toolkit-docs-generator/src/shared/toolkit-data-dir"; +import { source } from "./source"; +import { loadAllToolkitData } from "./toolkit-data"; +import { getToolkitCanonicalPath } from "./toolkit-static-params"; + +const LOCALE_PREFIX = /^\/en\/?/; +const PATH_SEPARATORS = /[/-]+/g; + +/** "/en/operate/deploy/on-prem" -> "operate deploy on prem": slug words are + * searchable, so "faq" finds /resources/faq and "on prem" finds on-prem. */ +function urlKeywords(url: string): string { + return url.replace(LOCALE_PREFIX, "").replace(PATH_SEPARATORS, " "); +} + +/** + * Docs search: Fumadocs' built-in search (ZBSearch), served at /api/search and + * used by the search dialog. + * + * - "simple" mode returns one result per page and boosts page titles, which + * ranks the page named after the query above long pages that mention it. + * - English stemming plus a typo tolerance of 2 lets swapped letters + * ("gmial") still match. + * - URL slugs are indexed as keywords, so "faq" finds /resources/faq even + * though the title is "Frequently Asked Questions". + * - Generated toolkit pages are not MDX, so they are indexed here from the + * toolkit JSON: tool names and descriptions become the page content. + */ +async function buildIndexes(): Promise { + const pages: Index[] = source.getPages().map((page) => ({ + title: page.data.title ?? "", + description: page.data.description, + url: page.url, + keywords: urlKeywords(page.url), + content: [ + ...page.data.structuredData.headings.map((h) => h.content), + ...page.data.structuredData.contents.map((c) => c.content), + ].join("\n"), + })); + + const { byNormalizedId } = await loadAllToolkitData(resolveToolkitDataDir()); + const toolkits: Index[] = []; + for (const toolkit of byNormalizedId.values()) { + if (toolkit.metadata.isHidden || !toolkit.metadata.category) { + continue; + } + toolkits.push({ + title: toolkit.label, + description: toolkit.description ?? undefined, + url: getToolkitCanonicalPath({ + id: toolkit.id, + category: toolkit.metadata.category, + docsLink: toolkit.metadata.docsLink, + }), + keywords: `${toolkit.id} ${toolkit.label} integration`, + content: [ + `${toolkit.label} MCP server`, + ...toolkit.tools.map( + (tool) => `${tool.qualifiedName}\n${tool.description ?? ""}` + ), + ].join("\n"), + }); + } + + return [...pages, ...toolkits]; +} + +export const builtinSearch = createSearchAPI("simple", { + indexes: buildIndexes, + tokenizer: { language: "english", stemming: true }, + search: { tolerance: 2, boost: { title: 2, keywords: 1.5 } }, +}); diff --git a/lib/search-suggestions.ts b/lib/search-suggestions.ts new file mode 100644 index 000000000..866a43d75 --- /dev/null +++ b/lib/search-suggestions.ts @@ -0,0 +1,15 @@ +import type { SearchLink } from "fumadocs-ui/contexts/search"; + +/** + * Pages shown in the search dialog before the user types anything. Each link + * must resolve to a page; tests/builtin-search.test.ts checks that. + */ +export const SEARCH_SUGGESTIONS: SearchLink[] = [ + ["Build an MCP server", "/en/get-started/quickstarts/mcp-server-quickstart"], + ["Call tools in your agent", "/en/get-started/quickstarts/call-tool-agent"], + ["Get your API key", "/en/get-started/setup/api-keys"], + ["Browse MCP servers", "/en/resources/integrations"], + ["Auth providers", "/en/references/auth-providers"], + ["Hosting options", "/en/operate/deploy"], + ["Arcade CLI cheat sheet", "/en/references/cli-cheat-sheet"], +]; diff --git a/package.json b/package.json index a5ccb7524..e67f71f02 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@ory/client": "1.22.37", "@shikijs/transformers": "4.5.0", "@uidotdev/usehooks": "2.4.1", - "algoliasearch": "5.53.0", "fumadocs-core": "16.15.18", "fumadocs-mdx": "15.4.6", "fumadocs-ui": "16.15.18", @@ -59,7 +58,6 @@ "react": "19.2.7", "react-dom": "19.2.7", "react-hook-form": "7.77.0", - "react-instantsearch": "7.34.0", "react-markdown": "10.1.0", "react-syntax-highlighter": "16.1.1", "remark-gfm": "4.0.1", diff --git a/scripts/sync-crawler-config.ts b/scripts/sync-crawler-config.ts deleted file mode 100644 index 0e36ec6c3..000000000 --- a/scripts/sync-crawler-config.ts +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env bun - -/** - * Push algolia/crawler-config.js to the Algolia Crawler, then trigger a reindex. - * - * Usage: - * bun run sync-crawler-config - * - * `PATCH /1/crawlers/{id}/config` is a top-level merge, so this only sends the - * fields we manage. We deliberately do NOT send `apiKey`: the crawler's index - * write key stays whatever it is in the Algolia dashboard and is never touched - * here (the config file doesn't store it either). Updating config doesn't crawl - * on its own, so we POST /reindex afterwards to apply the new config. - * - * `initialIndexSettings` in the crawler config only applies when the crawler - * *creates* the index — it is not re-applied to an existing index on subsequent - * crawls. To keep the live production index in sync with the versioned settings, - * we also push them via the Search API's PUT /1/indexes/{name}/settings when - * ALGOLIA_ADMIN_API_KEY is available. Without that key, we skip with a warning - * and the crawler config still updates normally. - * - * Requires these env vars (provided as CI secrets/variables): - * ALGOLIA_CRAWLER_ID - * ALGOLIA_CRAWLER_USER_ID - * ALGOLIA_CRAWLER_API_KEY - * - * Optional: - * ALGOLIA_ADMIN_API_KEY (needed to apply index settings to existing indices) - */ - -import { Buffer } from "node:buffer"; -import { crawlerConfig } from "../algolia/crawler-config.js"; - -const CRAWLER_ID = process.env.ALGOLIA_CRAWLER_ID; -const USER_ID = process.env.ALGOLIA_CRAWLER_USER_ID; -const API_KEY = process.env.ALGOLIA_CRAWLER_API_KEY; -const ADMIN_API_KEY = process.env.ALGOLIA_ADMIN_API_KEY; - -if (!(CRAWLER_ID && USER_ID && API_KEY)) { - console.error( - "Missing ALGOLIA_CRAWLER_ID, ALGOLIA_CRAWLER_USER_ID, or ALGOLIA_CRAWLER_API_KEY." - ); - process.exit(1); -} - -const API_BASE = "https://crawler.algolia.com/api/1"; -const authHeader = `Basic ${Buffer.from(`${USER_ID}:${API_KEY}`).toString("base64")}`; - -// The Crawler API stores recordExtractor as { __type: "function", source }, so -// serialize each action's function to its source string. Spreading crawlerConfig -// keeps every other managed field (schedule, sitemaps, actions, settings, …); -// appId is a harmless no-op in a merge and apiKey is intentionally absent. -const payload = { - ...crawlerConfig, - actions: crawlerConfig.actions.map((action) => ({ - ...action, - recordExtractor: { - __type: "function", - source: action.recordExtractor.toString(), - }, - })), -}; - -const configResponse = await fetch(`${API_BASE}/crawlers/${CRAWLER_ID}/config`, { - method: "PATCH", - headers: { Authorization: authHeader, "Content-Type": "application/json" }, - body: JSON.stringify(payload), -}); - -if (!configResponse.ok) { - console.error( - `Config update failed (${configResponse.status}): ${await configResponse.text()}` - ); - process.exit(1); -} -console.log("Crawler config updated."); - -// Push initialIndexSettings to each live index via the Search API. The crawler -// only applies these when the index is first created, so a settings change -// (e.g. removeWordsIfNoResults) never reaches an existing production index -// otherwise, and no-result rates stay high for compound agent queries. -if (ADMIN_API_KEY) { - const settingsByIndex = crawlerConfig.initialIndexSettings; - for (const [indexName, settings] of Object.entries(settingsByIndex)) { - const response = await fetch( - `https://${crawlerConfig.appId}-dsn.algolia.net/1/indexes/${encodeURIComponent(indexName)}/settings`, - { - method: "PUT", - headers: { - "X-Algolia-Application-Id": crawlerConfig.appId, - "X-Algolia-API-Key": ADMIN_API_KEY, - "Content-Type": "application/json", - }, - body: JSON.stringify(settings), - } - ); - if (!response.ok) { - console.error( - `Index settings update failed for ${indexName} (${response.status}): ${await response.text()}` - ); - process.exit(1); - } - console.log(`Index settings pushed to ${indexName}.`); - } -} else { - console.warn( - "ALGOLIA_ADMIN_API_KEY not set — skipping Search API settings push. " + - "The crawler's initialIndexSettings only apply on index creation, so " + - "changes to those fields will not reach the existing production index " + - "until this key is provided. See algolia/README.md." - ); -} - -if (process.env.SKIP_REINDEX === "true") { - console.log("Skipping reindex — will be triggered after deployment."); - process.exit(0); -} - -const reindexResponse = await fetch( - `${API_BASE}/crawlers/${CRAWLER_ID}/reindex`, - { method: "POST", headers: { Authorization: authHeader } } -); - -if (!reindexResponse.ok) { - console.error( - `Reindex failed (${reindexResponse.status}): ${await reindexResponse.text()}` - ); - process.exit(1); -} - -const { taskId } = (await reindexResponse.json()) as { taskId: string }; -console.log(`Reindex triggered (taskId: ${taskId}).`); diff --git a/tests/builtin-search.test.ts b/tests/builtin-search.test.ts new file mode 100644 index 000000000..68b1ae234 --- /dev/null +++ b/tests/builtin-search.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { GET } from "@/app/api/search/route"; +import { builtinSearch } from "@/lib/builtin-search"; +import { SEARCH_SUGGESTIONS } from "@/lib/search-suggestions"; +import { source } from "@/lib/source"; + +// lib/source.ts compiles every MDX page, which Vitest can't do cheaply (see +// tests/helpers/fs-source.ts). Index the pages from disk instead, using each +// page's title, slug and description. MDX bodies are not indexed here, so these +// cases cover title, slug and toolkit matching, typo tolerance and ranking. +vi.mock("@/lib/source", async () => { + const { createFsSource } = await import("./helpers/fs-source"); + const fsSource = createFsSource(); + return { + source: { + getPages: () => + fsSource.getPages().map((page) => ({ + ...page, + data: { + ...page.data, + structuredData: { + headings: [], + contents: [ + { heading: undefined, content: page.data.description ?? "" }, + ], + }, + }, + })), + }, + }; +}); + +type SearchResult = { url: string; content: string }; + +async function search(query: string): Promise { + const response = await builtinSearch.GET( + new Request( + `http://localhost/api/search?query=${encodeURIComponent(query)}` + ) + ); + const results = (await response.json()) as SearchResult[]; + return results.map((result) => result.url); +} + +// Each query must return the expected page within the top three results. +const EXPECTATIONS: [query: string, url: string][] = [ + ["api key", "/en/get-started/setup/api-keys"], + ["oauth", "/en/references/auth-providers/oauth2"], + ["audit logs", "/en/operate/governance/audit-logs"], + ["mcp gateway", "/en/operate/governance/mcp-gateways"], + // Generated toolkit pages are indexed from the toolkit JSON. + ["gmail", "/en/resources/integrations/productivity/gmail"], + ["Gmail.SendEmail", "/en/resources/integrations/productivity/gmail"], + // Typo tolerance. + ["gmial", "/en/resources/integrations/productivity/gmail"], +]; + +describe("builtin search", () => { + it.each(EXPECTATIONS)("%s finds %s", async (query, url) => { + const urls = await search(query); + expect(urls.slice(0, 3)).toContain(url); + }); + + it("returns no results for an empty query", async () => { + expect(await search("")).toEqual([]); + }); +}); + +describe("search suggestions", () => { + it("link to pages that exist", () => { + const urls = new Set(source.getPages().map((page) => page.url)); + for (const [, url] of SEARCH_SUGGESTIONS) { + expect(urls.has(url), `${url} is not a docs page`).toBe(true); + } + }); +}); + +describe("/api/search route", () => { + const get = (query: string) => + GET( + new Request( + `http://localhost/api/search?query=${encodeURIComponent(query)}` + ) + ); + + it("lets a CDN cache responses", async () => { + const response = await get("gmail"); + expect(response.headers.get("Cache-Control")).toContain("s-maxage"); + }); + + it("cuts very long queries instead of rejecting them", async () => { + const question = "how do I send an email from my agent with gmail "; + const longQuery = question.repeat(100); + const response = await get(longQuery); + expect(response.status).toBe(200); + + // The same results as the first 200 characters on their own. + const expected = await ( + await get(longQuery.slice(0, 200).trimEnd()) + ).json(); + expect(await response.json()).toEqual(expected); + }); +}); diff --git a/toolkit-docs-generator/ARCHITECTURE.md b/toolkit-docs-generator/ARCHITECTURE.md index 65e0350e6..2404b30df 100644 --- a/toolkit-docs-generator/ARCHITECTURE.md +++ b/toolkit-docs-generator/ARCHITECTURE.md @@ -133,14 +133,10 @@ statically render the toolkit routes at build time from the committed JSON. ## Search indexing -Search uses an external Algolia crawler. There is no Pagefind or local search -index build step in this repository. After deployment, the crawler indexes the -rendered site. `components/algolia-search.tsx` queries that index with the -public, read-only values configured through these Vercel environment variables: - -- `NEXT_PUBLIC_ALGOLIA_APP_ID` -- `NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY` -- `NEXT_PUBLIC_ALGOLIA_INDEX_NAME` +Search uses Fumadocs' built-in search. `lib/builtin-search.ts` builds the index +when the `/api/search` route is first called: MDX pages come from the Fumadocs +source and toolkit pages come from the toolkit JSON. There is no external +crawler or reindex step, so new pages appear in search as soon as they deploy. ## Key files