diff --git a/apps/content/blume.config.ts b/apps/content/blume.config.ts index ffb1378c0..799d1768b 100644 --- a/apps/content/blume.config.ts +++ b/apps/content/blume.config.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'blume' +import { searchCodeIndexPlugin } from './search/code-index' import { sponsorAdsInjectPlugin } from './sponsors/inject' export default defineConfig({ @@ -102,6 +103,19 @@ export default defineConfig({ }, }, }, + { + // Blume drops fenced code from the search index; fold it back in so the + // code-first docs are searchable by the API they demonstrate. + name: 'search-code-index', + hooks: { + 'astro:config:setup': ({ updateConfig, injectScript }) => { + updateConfig({ vite: { plugins: [searchCodeIndexPlugin()] } }) + // Render the matched passage in the preview pane the way the page does. + const clientPath = fileURLToPath(new URL('./search/page-preview.ts', import.meta.url)) + injectScript('page', `import '${clientPath.replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}'`) + }, + }, + }, { name: 'sponsors', hooks: { diff --git a/apps/content/search/code-index.ts b/apps/content/search/code-index.ts new file mode 100644 index 000000000..6160e9e51 --- /dev/null +++ b/apps/content/search/code-index.ts @@ -0,0 +1,125 @@ +/** + * Blume's search index strips fenced code before indexing (`toPlainText` in + * `blume/src/search/documents.ts`), so a query like `createSafeClient` or + * `onSuccess` only matches pages that also happen to name it in prose. On oRPC's + * docs that hides most of the answer: the pages are code-first, and the snippet + * a reader is hunting for usually lives inside a ```ts fence. + * + * The generated index (`.blume/src/generated/search.json`) is imported through + * Vite by the generated `/blume-search.json` endpoint, so a `pre` transform can + * fold the code back in before it is served. The full Markdown for every route + * sits next to it in `raw-markdown.json` (Blume generates it for the raw `.md` + * URLs), which is where the fences come from — no second content pass. + * + * Working on the generated file rather than a fork of Blume's document builder + * keeps this to one hook: the index keeps its shape, and Orama, FlexSearch, the + * preview pane and the hosted syncs all read the enriched `content` unchanged. + */ + +import type { Plugin } from 'vite' +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { stringifyJSON } from '@orpc/shared' + +/** The generated module this plugin rewrites, matched against Vite's ids. */ +const SEARCH_JSON = '/.blume/src/generated/search.json' +/** Sibling holding each route's full Markdown, fences included. */ +const RAW_MARKDOWN = 'raw-markdown.json' + +/** + * Opening fence, its info string, body, and closing fence of the same length. + * The info string cannot open with a backtick, so it can never absorb part of + * the opening run — the ambiguity that would let a long line of backticks + * backtrack quadratically. + */ +const FENCE = /^[ \t]*(?`{3,})(?(?:[^\n`][^\n]*)?)\n(?[\s\S]*?)^[ \t]*\k[ \t]*$/gmu + +/** + * Fence bodies Blume renders as something other than code, so their text is + * chrome rather than content: `package-install` expands to a tabbed install + * widget, and Mermaid to a diagram. + */ +const NON_CODE_LANGUAGES = new Set(['mermaid', 'package-install']) + +/** Shiki transformer notations (`// [!code highlight]`) — markup, not code. */ +const NOTATION = /\s*(?:\/\/|#|