blume 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/bin/blume.mjs +18 -0
  2. package/dist/cli/index.js +12696 -0
  3. package/dist/cli/index.js.map +145 -0
  4. package/dist/types/core/bridge.d.ts +24 -0
  5. package/dist/types/core/config.d.ts +35 -0
  6. package/dist/types/core/data.d.ts +129 -0
  7. package/dist/types/core/define-components.d.ts +27 -0
  8. package/dist/types/core/define-meta.d.ts +16 -0
  9. package/dist/types/core/deployment-env.d.ts +9 -0
  10. package/dist/types/core/diagnostics.d.ts +17 -0
  11. package/dist/types/core/i18n-ui.d.ts +500 -0
  12. package/dist/types/core/load-module.d.ts +7 -0
  13. package/dist/types/core/package-root.d.ts +17 -0
  14. package/dist/types/core/project.d.ts +9 -0
  15. package/dist/types/core/schema.d.ts +3451 -0
  16. package/dist/types/core/sources/types.d.ts +107 -0
  17. package/dist/types/core/types.d.ts +245 -0
  18. package/dist/types/core/ui-packs/ar.d.ts +3 -0
  19. package/dist/types/core/ui-packs/bg.d.ts +3 -0
  20. package/dist/types/core/ui-packs/bn.d.ts +3 -0
  21. package/dist/types/core/ui-packs/ca.d.ts +3 -0
  22. package/dist/types/core/ui-packs/cs.d.ts +3 -0
  23. package/dist/types/core/ui-packs/da.d.ts +3 -0
  24. package/dist/types/core/ui-packs/de.d.ts +3 -0
  25. package/dist/types/core/ui-packs/el.d.ts +3 -0
  26. package/dist/types/core/ui-packs/es.d.ts +3 -0
  27. package/dist/types/core/ui-packs/fa.d.ts +3 -0
  28. package/dist/types/core/ui-packs/fi.d.ts +3 -0
  29. package/dist/types/core/ui-packs/fr.d.ts +3 -0
  30. package/dist/types/core/ui-packs/he.d.ts +3 -0
  31. package/dist/types/core/ui-packs/hi.d.ts +3 -0
  32. package/dist/types/core/ui-packs/hr.d.ts +3 -0
  33. package/dist/types/core/ui-packs/hu.d.ts +3 -0
  34. package/dist/types/core/ui-packs/id.d.ts +3 -0
  35. package/dist/types/core/ui-packs/index.d.ts +13 -0
  36. package/dist/types/core/ui-packs/it.d.ts +3 -0
  37. package/dist/types/core/ui-packs/ja.d.ts +3 -0
  38. package/dist/types/core/ui-packs/ko.d.ts +3 -0
  39. package/dist/types/core/ui-packs/nl.d.ts +3 -0
  40. package/dist/types/core/ui-packs/no.d.ts +3 -0
  41. package/dist/types/core/ui-packs/pl.d.ts +3 -0
  42. package/dist/types/core/ui-packs/pt-br.d.ts +3 -0
  43. package/dist/types/core/ui-packs/pt.d.ts +3 -0
  44. package/dist/types/core/ui-packs/ro.d.ts +3 -0
  45. package/dist/types/core/ui-packs/ru.d.ts +3 -0
  46. package/dist/types/core/ui-packs/sk.d.ts +3 -0
  47. package/dist/types/core/ui-packs/sr.d.ts +3 -0
  48. package/dist/types/core/ui-packs/sv.d.ts +3 -0
  49. package/dist/types/core/ui-packs/th.d.ts +3 -0
  50. package/dist/types/core/ui-packs/tr.d.ts +3 -0
  51. package/dist/types/core/ui-packs/uk.d.ts +3 -0
  52. package/dist/types/core/ui-packs/vi.d.ts +3 -0
  53. package/dist/types/core/ui-packs/zh-tw.d.ts +3 -0
  54. package/dist/types/core/ui-packs/zh.d.ts +3 -0
  55. package/dist/types/core/version.d.ts +8 -0
  56. package/dist/types/index.d.ts +10 -0
  57. package/dist/types/migrate/mintlify/config.d.ts +2 -0
  58. package/dist/types/migrate/mintlify/i18n.d.ts +7 -0
  59. package/dist/types/theme/fonts.d.ts +163 -0
  60. package/docs/01-quickstart.mdx +99 -0
  61. package/docs/02-deployment.mdx +129 -0
  62. package/docs/advanced/api-reference.mdx +114 -0
  63. package/docs/advanced/blog.mdx +121 -0
  64. package/docs/advanced/changelog.mdx +113 -0
  65. package/docs/advanced/custom-pages.mdx +268 -0
  66. package/docs/advanced/meta.ts +7 -0
  67. package/docs/changelog/v0-1-0.mdx +12 -0
  68. package/docs/changelog/v0-2-0.mdx +16 -0
  69. package/docs/configuration/ai.mdx +228 -0
  70. package/docs/configuration/analytics.mdx +98 -0
  71. package/docs/configuration/customization.mdx +91 -0
  72. package/docs/configuration/export.mdx +70 -0
  73. package/docs/configuration/index.mdx +290 -0
  74. package/docs/configuration/meta.ts +15 -0
  75. package/docs/configuration/search.mdx +172 -0
  76. package/docs/configuration/seo.mdx +196 -0
  77. package/docs/configuration/theming.mdx +178 -0
  78. package/docs/content/components.mdx +651 -0
  79. package/docs/content/i18n.mdx +205 -0
  80. package/docs/content/index.mdx +161 -0
  81. package/docs/content/islands.mdx +94 -0
  82. package/docs/content/meta.mdx +119 -0
  83. package/docs/content/meta.ts +15 -0
  84. package/docs/content/navigation.mdx +168 -0
  85. package/docs/content/sources.mdx +216 -0
  86. package/docs/content/syntax.mdx +445 -0
  87. package/docs/index.mdx +112 -0
  88. package/docs/reference/cli.mdx +43 -0
  89. package/docs/reference/frontmatter.mdx +74 -0
  90. package/docs/reference/meta.ts +7 -0
  91. package/package.json +150 -6
  92. package/src/ai/ask.ts +93 -0
  93. package/src/ai/llms.ts +64 -0
  94. package/src/ai/markdown.ts +31 -0
  95. package/src/ai/mcp/data.ts +74 -0
  96. package/src/ai/mcp/discovery.ts +49 -0
  97. package/src/ai/mcp/server.ts +225 -0
  98. package/src/ai/mcp/tools.ts +47 -0
  99. package/src/assets/icon.png +0 -0
  100. package/src/astro/examples.ts +104 -0
  101. package/src/astro/generate.ts +1006 -0
  102. package/src/astro/index.ts +4 -0
  103. package/src/astro/integration.ts +74 -0
  104. package/src/astro/islands.ts +131 -0
  105. package/src/astro/markdown-negotiation.ts +68 -0
  106. package/src/astro/pages.ts +87 -0
  107. package/src/astro/templates.ts +1326 -0
  108. package/src/cli/commands/add.ts +81 -0
  109. package/src/cli/commands/build.ts +103 -0
  110. package/src/cli/commands/dev.ts +108 -0
  111. package/src/cli/commands/doctor.ts +74 -0
  112. package/src/cli/commands/eject.ts +57 -0
  113. package/src/cli/commands/init.ts +98 -0
  114. package/src/cli/commands/migrate.ts +39 -0
  115. package/src/cli/commands/preview.ts +39 -0
  116. package/src/cli/commands/sync.ts +52 -0
  117. package/src/cli/commands/validate.ts +61 -0
  118. package/src/cli/index.ts +35 -0
  119. package/src/cli/log.ts +37 -0
  120. package/src/cli/prepare.ts +80 -0
  121. package/src/components/Icon.astro +99 -0
  122. package/src/components/content/Accordion.astro +8 -0
  123. package/src/components/content/AccordionItem.astro +121 -0
  124. package/src/components/content/AutoTypeTable.astro +51 -0
  125. package/src/components/content/Badge.astro +124 -0
  126. package/src/components/content/Callout.astro +73 -0
  127. package/src/components/content/Card.astro +104 -0
  128. package/src/components/content/CardGroup.astro +14 -0
  129. package/src/components/content/CodeBlock.astro +28 -0
  130. package/src/components/content/CodeGroup.astro +13 -0
  131. package/src/components/content/Color.astro +15 -0
  132. package/src/components/content/ColorItem.astro +87 -0
  133. package/src/components/content/ColorRow.astro +10 -0
  134. package/src/components/content/Column.astro +6 -0
  135. package/src/components/content/Columns.astro +9 -0
  136. package/src/components/content/Component.astro +65 -0
  137. package/src/components/content/Diff.astro +44 -0
  138. package/src/components/content/Expandable.astro +11 -0
  139. package/src/components/content/FileTree.astro +8 -0
  140. package/src/components/content/Frame.astro +70 -0
  141. package/src/components/content/GithubInfo.astro +110 -0
  142. package/src/components/content/Math.astro +24 -0
  143. package/src/components/content/Panel.astro +20 -0
  144. package/src/components/content/Prompt.astro +129 -0
  145. package/src/components/content/Step.astro +34 -0
  146. package/src/components/content/Steps.astro +20 -0
  147. package/src/components/content/Tab.astro +46 -0
  148. package/src/components/content/Tabs.astro +273 -0
  149. package/src/components/content/Tile.astro +42 -0
  150. package/src/components/content/Tooltip.astro +68 -0
  151. package/src/components/content/Tree.astro +300 -0
  152. package/src/components/content/TreeFile.astro +15 -0
  153. package/src/components/content/TreeFolder.astro +62 -0
  154. package/src/components/content/TypeTable.astro +106 -0
  155. package/src/components/content/Update.astro +66 -0
  156. package/src/components/content/Visibility.astro +12 -0
  157. package/src/components/content/Warning.astro +9 -0
  158. package/src/components/content/auto-type-table.ts +141 -0
  159. package/src/components/content/diff.ts +95 -0
  160. package/src/components/content/github-info.ts +79 -0
  161. package/src/components/content/mermaid-element.ts +68 -0
  162. package/src/components/github-mark.ts +9 -0
  163. package/src/components/index.ts +14 -0
  164. package/src/components/islands/AskAI.astro +12 -0
  165. package/src/components/islands/ask-ai.tsx +156 -0
  166. package/src/components/layout/Analytics.astro +63 -0
  167. package/src/components/layout/Banner.astro +50 -0
  168. package/src/components/layout/Breadcrumbs.astro +31 -0
  169. package/src/components/layout/Favicon.astro +22 -0
  170. package/src/components/layout/Fonts.astro +14 -0
  171. package/src/components/layout/Header.astro +188 -0
  172. package/src/components/layout/LanguageSwitcher.astro +56 -0
  173. package/src/components/layout/NavTree.astro +462 -0
  174. package/src/components/layout/PageActions.astro +438 -0
  175. package/src/components/layout/PageFeedback.astro +58 -0
  176. package/src/components/layout/PageLayout.astro +173 -0
  177. package/src/components/layout/Pagination.astro +56 -0
  178. package/src/components/layout/ReferenceLayout.astro +107 -0
  179. package/src/components/layout/RootLayout.astro +537 -0
  180. package/src/components/layout/Search.astro +608 -0
  181. package/src/components/layout/TableOfContents.astro +68 -0
  182. package/src/components/layout/analytics-client.ts +38 -0
  183. package/src/components/layout/head-scripts.ts +19 -0
  184. package/src/components/layout/nav-utils.ts +87 -0
  185. package/src/components/layout/overrides.ts +32 -0
  186. package/src/components/layout/search/algolia.ts +43 -0
  187. package/src/components/layout/search/endpoint.ts +22 -0
  188. package/src/components/layout/search/flexsearch.ts +52 -0
  189. package/src/components/layout/search/orama-cloud.ts +41 -0
  190. package/src/components/layout/search/orama.ts +26 -0
  191. package/src/components/layout/search/pagefind.ts +43 -0
  192. package/src/components/layout/search/types.ts +163 -0
  193. package/src/components/layout/search/typesense.ts +60 -0
  194. package/src/components/layout/toc-element.ts +108 -0
  195. package/src/core/bridge.ts +92 -0
  196. package/src/core/config.ts +112 -0
  197. package/src/core/content.ts +50 -0
  198. package/src/core/data.ts +128 -0
  199. package/src/core/define-components.ts +34 -0
  200. package/src/core/define-meta.ts +20 -0
  201. package/src/core/deployment-env.ts +73 -0
  202. package/src/core/diagnostics.ts +104 -0
  203. package/src/core/frontmatter.ts +43 -0
  204. package/src/core/graph.ts +128 -0
  205. package/src/core/i18n-ui.ts +171 -0
  206. package/src/core/i18n.ts +169 -0
  207. package/src/core/last-modified.ts +88 -0
  208. package/src/core/links.ts +352 -0
  209. package/src/core/load-module.ts +15 -0
  210. package/src/core/manifest.ts +126 -0
  211. package/src/core/meta.ts +97 -0
  212. package/src/core/navigation.ts +392 -0
  213. package/src/core/package-root.ts +37 -0
  214. package/src/core/project-graph.ts +153 -0
  215. package/src/core/project.ts +56 -0
  216. package/src/core/schema.ts +1057 -0
  217. package/src/core/server-features.ts +23 -0
  218. package/src/core/sources/assets.ts +77 -0
  219. package/src/core/sources/cache.ts +122 -0
  220. package/src/core/sources/filesystem.ts +99 -0
  221. package/src/core/sources/mdx-remote.ts +215 -0
  222. package/src/core/sources/mintlify.ts +161 -0
  223. package/src/core/sources/normalize.ts +243 -0
  224. package/src/core/sources/notion.ts +440 -0
  225. package/src/core/sources/portable-text.ts +143 -0
  226. package/src/core/sources/read.ts +36 -0
  227. package/src/core/sources/resolve.ts +158 -0
  228. package/src/core/sources/sanity.ts +218 -0
  229. package/src/core/sources/types.ts +105 -0
  230. package/src/core/tsconfig-aliases.ts +201 -0
  231. package/src/core/types.ts +261 -0
  232. package/src/core/ui-packs/ar.ts +47 -0
  233. package/src/core/ui-packs/bg.ts +47 -0
  234. package/src/core/ui-packs/bn.ts +47 -0
  235. package/src/core/ui-packs/ca.ts +47 -0
  236. package/src/core/ui-packs/cs.ts +47 -0
  237. package/src/core/ui-packs/da.ts +47 -0
  238. package/src/core/ui-packs/de.ts +47 -0
  239. package/src/core/ui-packs/el.ts +47 -0
  240. package/src/core/ui-packs/es.ts +47 -0
  241. package/src/core/ui-packs/fa.ts +47 -0
  242. package/src/core/ui-packs/fi.ts +47 -0
  243. package/src/core/ui-packs/fr.ts +47 -0
  244. package/src/core/ui-packs/he.ts +47 -0
  245. package/src/core/ui-packs/hi.ts +47 -0
  246. package/src/core/ui-packs/hr.ts +47 -0
  247. package/src/core/ui-packs/hu.ts +47 -0
  248. package/src/core/ui-packs/id.ts +47 -0
  249. package/src/core/ui-packs/index.ts +87 -0
  250. package/src/core/ui-packs/it.ts +47 -0
  251. package/src/core/ui-packs/ja.ts +47 -0
  252. package/src/core/ui-packs/ko.ts +47 -0
  253. package/src/core/ui-packs/nl.ts +47 -0
  254. package/src/core/ui-packs/no.ts +47 -0
  255. package/src/core/ui-packs/pl.ts +47 -0
  256. package/src/core/ui-packs/pt-br.ts +47 -0
  257. package/src/core/ui-packs/pt.ts +47 -0
  258. package/src/core/ui-packs/ro.ts +47 -0
  259. package/src/core/ui-packs/ru.ts +47 -0
  260. package/src/core/ui-packs/sk.ts +47 -0
  261. package/src/core/ui-packs/sr.ts +47 -0
  262. package/src/core/ui-packs/sv.ts +47 -0
  263. package/src/core/ui-packs/th.ts +47 -0
  264. package/src/core/ui-packs/tr.ts +47 -0
  265. package/src/core/ui-packs/uk.ts +47 -0
  266. package/src/core/ui-packs/vi.ts +47 -0
  267. package/src/core/ui-packs/zh-tw.ts +47 -0
  268. package/src/core/ui-packs/zh.ts +47 -0
  269. package/src/core/version.ts +23 -0
  270. package/src/deploy/robots.ts +20 -0
  271. package/src/deploy/rss.ts +128 -0
  272. package/src/deploy/sitemap.ts +28 -0
  273. package/src/index.ts +39 -0
  274. package/src/markdown/code-title.ts +71 -0
  275. package/src/markdown/directives.ts +83 -0
  276. package/src/markdown/heading-anchors.ts +137 -0
  277. package/src/markdown/index.ts +228 -0
  278. package/src/markdown/inline-code.ts +108 -0
  279. package/src/markdown/language-icon.ts +172 -0
  280. package/src/markdown/math.ts +32 -0
  281. package/src/markdown/mdast.ts +48 -0
  282. package/src/markdown/mermaid.ts +37 -0
  283. package/src/markdown/package-commands.ts +159 -0
  284. package/src/markdown/package-install.ts +40 -0
  285. package/src/migrate/fumadocs/config.ts +155 -0
  286. package/src/migrate/fumadocs/content.ts +365 -0
  287. package/src/migrate/fumadocs/frontmatter.ts +18 -0
  288. package/src/migrate/fumadocs/groups.ts +230 -0
  289. package/src/migrate/fumadocs/index.ts +337 -0
  290. package/src/migrate/fumadocs/meta.ts +244 -0
  291. package/src/migrate/migrate.ts +53 -0
  292. package/src/migrate/mintlify/config.ts +1040 -0
  293. package/src/migrate/mintlify/content.ts +98 -0
  294. package/src/migrate/mintlify/frontmatter.ts +126 -0
  295. package/src/migrate/mintlify/i18n.ts +51 -0
  296. package/src/migrate/mintlify/icons.ts +128 -0
  297. package/src/migrate/mintlify/index.ts +266 -0
  298. package/src/migrate/mintlify/snippets.ts +306 -0
  299. package/src/migrate/mintlify/transform.ts +80 -0
  300. package/src/migrate/nextra/content.ts +46 -0
  301. package/src/migrate/nextra/frontmatter.ts +40 -0
  302. package/src/migrate/nextra/index.ts +374 -0
  303. package/src/migrate/nextra/meta.ts +266 -0
  304. package/src/migrate/shared.ts +720 -0
  305. package/src/migrate/starlight/config.ts +459 -0
  306. package/src/migrate/starlight/content.ts +78 -0
  307. package/src/migrate/starlight/frontmatter.ts +111 -0
  308. package/src/migrate/starlight/i18n.ts +54 -0
  309. package/src/migrate/starlight/index.ts +131 -0
  310. package/src/og/card.ts +92 -0
  311. package/src/og/index.ts +2 -0
  312. package/src/openapi/scalar.ts +246 -0
  313. package/src/registry/eject.ts +310 -0
  314. package/src/registry/registry.ts +100 -0
  315. package/src/registry/rewrite-imports.ts +39 -0
  316. package/src/runtime/index.ts +14 -0
  317. package/src/search/build.ts +23 -0
  318. package/src/search/documents.ts +164 -0
  319. package/src/search/orama-index.ts +66 -0
  320. package/src/search/providers.ts +91 -0
  321. package/src/search/sync/algolia.ts +30 -0
  322. package/src/search/sync/index.ts +50 -0
  323. package/src/search/sync/orama-cloud.ts +40 -0
  324. package/src/search/sync/typesense.ts +65 -0
  325. package/src/seo/jsonld.ts +113 -0
  326. package/src/theme/entry.ts +637 -0
  327. package/src/theme/fonts.ts +198 -0
  328. package/src/theme/icons.ts +184 -0
  329. package/src/theme/palette.ts +143 -0
  330. package/src/theme/twoslash.ts +81 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Send a custom analytics event to every analytics platform configured in
3
+ * `blume.config.ts`. Mirrors the providers wired by `Analytics.astro`: Vercel
4
+ * Web Analytics and PostHog are first-class; any other provider added through
5
+ * `analytics.scripts` is reached via best-effort global detection or the
6
+ * `blume:track` CustomEvent, which fires unconditionally so a project can bridge
7
+ * the event to anything. Every call no-ops cleanly when a provider isn't present
8
+ * — for example during `blume dev`, where `Analytics.astro` injects nothing.
9
+ */
10
+ import { track as vercelTrack } from "@vercel/analytics";
11
+
12
+ /** Flat, serializable event properties. */
13
+ export type TrackProps = Record<string, boolean | number | string>;
14
+
15
+ interface AnalyticsWindow {
16
+ gtag?: (command: "event", event: string, props?: TrackProps) => void;
17
+ plausible?: (event: string, options?: { props?: TrackProps }) => void;
18
+ posthog?: { capture?: (event: string, props?: TrackProps) => void };
19
+ }
20
+
21
+ export const track = (event: string, props: TrackProps): void => {
22
+ if (typeof window === "undefined") {
23
+ return;
24
+ }
25
+ const w = window as typeof window & AnalyticsWindow;
26
+
27
+ // Vercel Web Analytics — self-gates to a no-op until `window.va` is set up.
28
+ vercelTrack(event, props);
29
+ // PostHog — the injected array.js stub queues calls until the lib loads.
30
+ w.posthog?.capture?.(event, props);
31
+ // Popular providers wired through `analytics.scripts` (GA4/GTM, Plausible).
32
+ w.gtag?.("event", event, props);
33
+ w.plausible?.(event, { props });
34
+ // Universal hook for any other integration.
35
+ window.dispatchEvent(
36
+ new CustomEvent("blume:track", { detail: { event, props } })
37
+ );
38
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pre-paint inline scripts shared by the document layouts (`RootLayout`,
3
+ * `SplashLayout`). They run synchronously in `<head>`, before first paint, so
4
+ * the page never flashes the wrong theme or a since-dismissed banner. Kept in
5
+ * one place so the two layouts can't drift on this timing-critical logic.
6
+ */
7
+
8
+ /**
9
+ * Set `data-theme` from the stored preference (or the configured default, or the
10
+ * OS setting for `"system"`) before the body paints, avoiding a theme flash.
11
+ */
12
+ export const themeInitScript = (
13
+ themeMode: "system" | "light" | "dark"
14
+ ): string =>
15
+ `(()=>{const m=${JSON.stringify(themeMode)};const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
16
+
17
+ /** Hide a previously-dismissed banner before it can flash in. */
18
+ export const bannerInitScript = (key: string): string =>
19
+ `(()=>{if(localStorage.getItem("blume-banner:"+${JSON.stringify(key)}))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
@@ -0,0 +1,87 @@
1
+ import type { NavNode } from "../../core/types.ts";
2
+
3
+ /** A flat, ordered page reference used for previous/next pagination. */
4
+ export interface FlatPage {
5
+ route: string;
6
+ label: string;
7
+ deprecated?: boolean;
8
+ }
9
+
10
+ /** A breadcrumb segment; `route` is absent for non-clickable group ancestors. */
11
+ export interface Crumb {
12
+ label: string;
13
+ route?: string;
14
+ }
15
+
16
+ /** Flatten the sidebar tree into ordered internal page links. */
17
+ export const flattenPages = (nodes: NavNode[]): FlatPage[] => {
18
+ const out: FlatPage[] = [];
19
+ const seen = new Set<string>();
20
+ const add = (page: FlatPage): void => {
21
+ if (seen.has(page.route)) {
22
+ return;
23
+ }
24
+ seen.add(page.route);
25
+ out.push(page);
26
+ };
27
+ const walk = (items: NavNode[]): void => {
28
+ for (const item of items) {
29
+ if (item.kind === "group") {
30
+ if (item.route) {
31
+ add({ label: item.label, route: item.route });
32
+ }
33
+ walk(item.children);
34
+ } else if (item.pageId) {
35
+ // Skip external links (no backing page).
36
+ add(
37
+ item.deprecated
38
+ ? { deprecated: true, label: item.label, route: item.route }
39
+ : { label: item.label, route: item.route }
40
+ );
41
+ }
42
+ }
43
+ };
44
+ walk(nodes);
45
+ return out;
46
+ };
47
+
48
+ /** Find the breadcrumb trail (group ancestors + page) for a route. */
49
+ export const findBreadcrumbs = (nodes: NavNode[], route: string): Crumb[] => {
50
+ const search = (items: NavNode[], trail: Crumb[]): Crumb[] | null => {
51
+ for (const item of items) {
52
+ if (item.kind === "page") {
53
+ if (item.route === route) {
54
+ return [...trail, { label: item.label, route: item.route }];
55
+ }
56
+ } else {
57
+ const crumb: Crumb = item.route
58
+ ? { label: item.label, route: item.route }
59
+ : { label: item.label };
60
+ if (item.route === route) {
61
+ return [...trail, crumb];
62
+ }
63
+ const found = search(item.children, [...trail, crumb]);
64
+ if (found) {
65
+ return found;
66
+ }
67
+ }
68
+ }
69
+ return null;
70
+ };
71
+ return search(nodes, []) ?? [];
72
+ };
73
+
74
+ /** Resolve previous/next pages around the current route. */
75
+ export const getPagination = (
76
+ flat: FlatPage[],
77
+ route: string
78
+ ): { prev: FlatPage | null; next: FlatPage | null } => {
79
+ const index = flat.findIndex((page) => page.route === route);
80
+ if (index === -1) {
81
+ return { next: null, prev: null };
82
+ }
83
+ return {
84
+ next: flat[index + 1] ?? null,
85
+ prev: index > 0 ? (flat[index - 1] ?? null) : null,
86
+ };
87
+ };
@@ -0,0 +1,32 @@
1
+ import type { ComponentOverride } from "../../core/define-components.ts";
2
+
3
+ /**
4
+ * Resolve a layout-slot override to the component Astro should render, falling
5
+ * back to Blume's built-in when no usable override is configured.
6
+ *
7
+ * An override may be a bare component reference or an `IslandDescriptor`
8
+ * (`{ component, client }`); only the component is used here. String-path
9
+ * overrides can't be imported at render time, so they fall back to the built-in
10
+ * for now (imported components are the recommended, type-safe form).
11
+ */
12
+ export const resolveSlot = <T>(
13
+ override: ComponentOverride | undefined,
14
+ fallback: T
15
+ ): T => {
16
+ if (
17
+ override === undefined ||
18
+ override === null ||
19
+ typeof override === "string"
20
+ ) {
21
+ return fallback;
22
+ }
23
+ if (
24
+ typeof override === "object" &&
25
+ "component" in override &&
26
+ override.component !== undefined &&
27
+ override.component !== null
28
+ ) {
29
+ return override.component as T;
30
+ }
31
+ return override as T;
32
+ };
@@ -0,0 +1,43 @@
1
+ import { liteClient } from "algoliasearch/lite";
2
+
3
+ import { excerptFor, highlight, SEARCH_LIMIT } from "./types.ts";
4
+ import type { SearchFn } from "./types.ts";
5
+
6
+ interface AlgoliaRecord {
7
+ url: string;
8
+ title: string;
9
+ description?: string;
10
+ content?: string;
11
+ }
12
+
13
+ /**
14
+ * Algolia: the browser queries the index directly with the public,
15
+ * search-only key. Records are uploaded at build time by the sync step.
16
+ */
17
+ export const createSearch = (opts: {
18
+ appId: string;
19
+ indexName: string;
20
+ searchApiKey: string;
21
+ }): SearchFn => {
22
+ const client = liteClient(opts.appId, opts.searchApiKey);
23
+ return async (query) => {
24
+ const { results } = await client.search<AlgoliaRecord>({
25
+ requests: [
26
+ { hitsPerPage: SEARCH_LIMIT, indexName: opts.indexName, query },
27
+ ],
28
+ });
29
+ const [first] = results;
30
+ const records =
31
+ first && "hits" in first ? (first.hits as AlgoliaRecord[]) : [];
32
+ const hits = records.map((record) => ({
33
+ content: record.content ?? "",
34
+ excerpt: highlight(
35
+ excerptFor(record.description ?? "", record.content ?? "", query),
36
+ query
37
+ ),
38
+ title: highlight(record.title, query),
39
+ url: record.url,
40
+ }));
41
+ return { hits, sections: [] };
42
+ };
43
+ };
@@ -0,0 +1,22 @@
1
+ import { SEARCH_LIMIT } from "./types.ts";
2
+ import type { SearchFn, SearchHit } from "./types.ts";
3
+
4
+ /**
5
+ * Server-proxied search (Mixedbread): POST the query to a generated endpoint
6
+ * that holds the secret key and talks to the service, then renders the
7
+ * already-shaped hits it returns.
8
+ */
9
+ export const createSearch =
10
+ (opts: { api: string }): SearchFn =>
11
+ async (query) => {
12
+ const response = await fetch(opts.api, {
13
+ body: JSON.stringify({ query }),
14
+ headers: { "Content-Type": "application/json" },
15
+ method: "POST",
16
+ });
17
+ if (!response.ok) {
18
+ return { hits: [], sections: [] };
19
+ }
20
+ const hits = (await response.json()) as SearchHit[];
21
+ return { hits: hits.slice(0, SEARCH_LIMIT), sections: [] };
22
+ };
@@ -0,0 +1,52 @@
1
+ import { Document } from "flexsearch";
2
+
3
+ import { buildResult, RESULT_POOL } from "./types.ts";
4
+ import type { IndexedDocument, SearchFn } from "./types.ts";
5
+
6
+ /**
7
+ * FlexSearch: reuse the same static `blume-search.json` Orama ships, but build
8
+ * a FlexSearch document index in the browser. Keyless; works in dev and build.
9
+ * Matched routes are resolved back to their full documents (so we never depend
10
+ * on `enrich` typing) and shaped by the shared `buildResult` helper.
11
+ */
12
+ export const createSearch = async (opts: {
13
+ indexUrl: string;
14
+ }): Promise<SearchFn> => {
15
+ const response = await fetch(opts.indexUrl);
16
+ const documents = (await response.json()) as IndexedDocument[];
17
+ const byRoute = new Map(documents.map((doc) => [doc.route, doc]));
18
+
19
+ const index = new Document({
20
+ document: { id: "route", index: ["title", "description", "content"] },
21
+ tokenize: "forward",
22
+ });
23
+ for (const doc of documents) {
24
+ // FlexSearch's `DocumentData` is an index-signature type; our concrete
25
+ // record satisfies it structurally but TS needs the cast.
26
+ index.add(doc as unknown as Record<string, string>);
27
+ }
28
+
29
+ // FlexSearch's in-memory search is synchronous, so the SearchFn resolves
30
+ // immediately rather than awaiting anything.
31
+ return (query, options) => {
32
+ const groups = index.search(query, { limit: RESULT_POOL });
33
+ const matched: IndexedDocument[] = [];
34
+ const seen = new Set<string>();
35
+ for (const group of groups) {
36
+ for (const id of group.result) {
37
+ const route = String(id);
38
+ if (seen.has(route)) {
39
+ continue;
40
+ }
41
+ seen.add(route);
42
+ const doc = byRoute.get(route);
43
+ // Filter to the active locale (when one is requested) before shaping,
44
+ // so section counts and results stay within the language.
45
+ if (doc && (!options?.locale || doc.locale === options.locale)) {
46
+ matched.push(doc);
47
+ }
48
+ }
49
+ }
50
+ return Promise.resolve(buildResult(matched, query, options?.section));
51
+ };
52
+ };
@@ -0,0 +1,41 @@
1
+ import { OramaClient } from "@oramacloud/client";
2
+
3
+ import { excerptFor, highlight, SEARCH_LIMIT } from "./types.ts";
4
+ import type { SearchFn } from "./types.ts";
5
+
6
+ interface OramaCloudRecord {
7
+ url: string;
8
+ title: string;
9
+ description?: string;
10
+ content?: string;
11
+ }
12
+
13
+ /**
14
+ * Orama Cloud: the browser queries the hosted index directly with the public
15
+ * endpoint + API key. Records are pushed at build time by the sync step.
16
+ */
17
+ export const createSearch = (opts: {
18
+ endpoint: string;
19
+ apiKey: string;
20
+ }): SearchFn => {
21
+ const client = new OramaClient({
22
+ api_key: opts.apiKey,
23
+ endpoint: opts.endpoint,
24
+ });
25
+ return async (query) => {
26
+ const results = await client.search({ limit: SEARCH_LIMIT, term: query });
27
+ const hits = (results?.hits ?? []).map((hit) => {
28
+ const doc = hit.document as unknown as OramaCloudRecord;
29
+ return {
30
+ content: doc.content ?? "",
31
+ excerpt: highlight(
32
+ excerptFor(doc.description ?? "", doc.content ?? "", query),
33
+ query
34
+ ),
35
+ title: highlight(doc.title, query),
36
+ url: doc.url,
37
+ };
38
+ });
39
+ return { hits, sections: [] };
40
+ };
41
+ };
@@ -0,0 +1,26 @@
1
+ import {
2
+ buildOramaIndex,
3
+ queryOramaIndex,
4
+ } from "../../../search/orama-index.ts";
5
+ import { buildResult, RESULT_POOL } from "./types.ts";
6
+ import type { IndexedDocument, SearchFn } from "./types.ts";
7
+
8
+ /**
9
+ * Orama (default): fetch the static `blume-search.json` index, build an
10
+ * in-memory full-text database in the browser, and query it. Keyless and
11
+ * available in both dev and the production build. A generous match pool is
12
+ * pulled so the section pills can count across the whole result set before the
13
+ * active filter and display limit are applied.
14
+ */
15
+ export const createSearch = async (opts: {
16
+ indexUrl: string;
17
+ }): Promise<SearchFn> => {
18
+ const response = await fetch(opts.indexUrl);
19
+ const documents = (await response.json()) as IndexedDocument[];
20
+ const db = await buildOramaIndex(documents);
21
+
22
+ return async (query, options) => {
23
+ const docs = await queryOramaIndex(db, query, RESULT_POOL, options?.locale);
24
+ return buildResult(docs as IndexedDocument[], query, options?.section);
25
+ };
26
+ };
@@ -0,0 +1,43 @@
1
+ import { highlight, SEARCH_LIMIT } from "./types.ts";
2
+ import type { SearchFn } from "./types.ts";
3
+
4
+ interface PagefindResult {
5
+ data: () => Promise<{
6
+ url: string;
7
+ excerpt: string;
8
+ meta?: { title?: string };
9
+ }>;
10
+ }
11
+
12
+ interface PagefindModule {
13
+ search: (query: string) => Promise<{ results: PagefindResult[] }>;
14
+ }
15
+
16
+ /**
17
+ * Pagefind: load the index emitted into the built site and query it. The bundle
18
+ * lives in the output (not `node_modules`), so it is imported at runtime by URL
19
+ * — which is why this only works in the production build, not `dev`.
20
+ */
21
+ export const createSearch = async (opts: {
22
+ url: string;
23
+ }): Promise<SearchFn> => {
24
+ const pagefind = (await import(
25
+ /* @vite-ignore */
26
+ opts.url
27
+ )) as PagefindModule;
28
+ // Pagefind builds its own marked-up excerpt; we keep that and only highlight
29
+ // the title. It carries no section/breadcrumb data, so pills stay hidden and
30
+ // the preview pane falls back to the excerpt.
31
+ return async (query) => {
32
+ const response = await pagefind.search(query);
33
+ const docs = await Promise.all(
34
+ response.results.slice(0, SEARCH_LIMIT).map((result) => result.data())
35
+ );
36
+ const hits = docs.map((doc) => ({
37
+ excerpt: doc.excerpt,
38
+ title: highlight(doc.meta?.title ?? doc.url, query),
39
+ url: doc.url,
40
+ }));
41
+ return { hits, sections: [] };
42
+ };
43
+ };
@@ -0,0 +1,163 @@
1
+ /** A single result rendered in the search dialog. */
2
+ export interface SearchHit {
3
+ url: string;
4
+ /** Title, possibly containing `<mark>` highlight markup. */
5
+ title: string;
6
+ /** Excerpt, possibly containing `<mark>` highlight markup. */
7
+ excerpt: string;
8
+ /** Ancestor section labels for the breadcrumb, e.g. `["Guides", "Auth"]`. */
9
+ breadcrumb?: string[];
10
+ /** Top-level section label, used by the filter pills. */
11
+ section?: string;
12
+ /** Plain-text page content, used to render the preview pane. */
13
+ content?: string;
14
+ }
15
+
16
+ /** A category pill with its result count. */
17
+ export interface SectionCount {
18
+ label: string;
19
+ count: number;
20
+ }
21
+
22
+ /** What every provider returns: ranked hits plus section facet counts. */
23
+ export interface SearchResult {
24
+ hits: SearchHit[];
25
+ sections: SectionCount[];
26
+ }
27
+
28
+ /** A configured query function — the common contract every provider returns. */
29
+ export type SearchFn = (
30
+ query: string,
31
+ options?: { section?: string; locale?: string }
32
+ ) => Promise<SearchResult>;
33
+
34
+ /** A document in the client-loaded `blume-search.json` index. */
35
+ export interface IndexedDocument {
36
+ route: string;
37
+ title: string;
38
+ description: string;
39
+ content: string;
40
+ breadcrumb?: string[];
41
+ section?: string;
42
+ locale?: string;
43
+ }
44
+
45
+ /** Max results surfaced in the dialog. */
46
+ export const SEARCH_LIMIT = 12;
47
+
48
+ /**
49
+ * How many ranked matches the static providers pull before filtering, so the
50
+ * section pills can count across more than just the visible page.
51
+ */
52
+ export const RESULT_POOL = 48;
53
+
54
+ const HTML_ESCAPES: Record<string, string> = {
55
+ '"': "&quot;",
56
+ "&": "&amp;",
57
+ "'": "&#39;",
58
+ "<": "&lt;",
59
+ ">": "&gt;",
60
+ };
61
+ const HTML_CHARS = /["&'<>]/gu;
62
+ const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
63
+ const WORD_BREAK = /\s+/u;
64
+
65
+ /** Escape HTML so untrusted text renders literally inside the dialog. */
66
+ export const escapeHtml = (text: string): string =>
67
+ text.replaceAll(HTML_CHARS, (char) => HTML_ESCAPES[char] ?? char);
68
+
69
+ /** Split a query into escaped, non-empty search tokens. */
70
+ const queryTokens = (query: string): string[] =>
71
+ query
72
+ .trim()
73
+ .split(WORD_BREAK)
74
+ .filter(Boolean)
75
+ .map((token) => token.replaceAll(REGEXP_SPECIAL, String.raw`\$&`));
76
+
77
+ /** Wrap query matches in `<mark>`, after HTML-escaping the source text. */
78
+ export const highlight = (text: string, query: string): string => {
79
+ const escaped = escapeHtml(text);
80
+ const tokens = queryTokens(query);
81
+ if (tokens.length === 0) {
82
+ return escaped;
83
+ }
84
+ const pattern = new RegExp(`(?<match>${tokens.join("|")})`, "giu");
85
+ return escaped.replaceAll(pattern, "<mark>$<match></mark>");
86
+ };
87
+
88
+ /** First index in `text` where any query token matches (case-insensitive). */
89
+ const matchIndex = (text: string, query: string): number => {
90
+ const tokens = queryTokens(query);
91
+ if (tokens.length === 0) {
92
+ return -1;
93
+ }
94
+ return text.search(new RegExp(tokens.join("|"), "iu"));
95
+ };
96
+
97
+ /**
98
+ * A plain-text window around the first query match, fenced with ellipses.
99
+ * Falls back to the head of the text when nothing matches.
100
+ */
101
+ export const matchSnippet = (
102
+ text: string,
103
+ query: string,
104
+ radius: number
105
+ ): string => {
106
+ const index = matchIndex(text, query);
107
+ if (index < 0) {
108
+ const head = text.slice(0, radius).trim();
109
+ return head.length < text.length ? `${head}…` : head;
110
+ }
111
+ const start = Math.max(0, index - Math.floor(radius / 3));
112
+ const end = Math.min(text.length, start + radius);
113
+ const slice = text.slice(start, end).trim();
114
+ return `${start > 0 ? "…" : ""}${slice}${end < text.length ? "…" : ""}`;
115
+ };
116
+
117
+ /** Build the excerpt shown under a result title. */
118
+ export const excerptFor = (
119
+ description: string,
120
+ content: string,
121
+ query?: string
122
+ ): string => {
123
+ if (query && matchIndex(content, query) >= 0) {
124
+ return matchSnippet(content, query, 160);
125
+ }
126
+ return description || `${content.slice(0, 140)}…`;
127
+ };
128
+
129
+ /** Tally how many matches fall into each section, in first-seen order. */
130
+ const countSections = (docs: IndexedDocument[]): SectionCount[] => {
131
+ const counts = new Map<string, number>();
132
+ for (const doc of docs) {
133
+ if (doc.section) {
134
+ counts.set(doc.section, (counts.get(doc.section) ?? 0) + 1);
135
+ }
136
+ }
137
+ return [...counts].map(([label, count]) => ({ count, label }));
138
+ };
139
+
140
+ /**
141
+ * Shared shaping for the static providers (Orama, FlexSearch): count sections
142
+ * across the full match pool, apply the active section filter, then map the
143
+ * visible slice to highlighted hits.
144
+ */
145
+ export const buildResult = (
146
+ docs: IndexedDocument[],
147
+ query: string,
148
+ section?: string
149
+ ): SearchResult => {
150
+ const sections = countSections(docs);
151
+ const filtered = section
152
+ ? docs.filter((doc) => doc.section === section)
153
+ : docs;
154
+ const hits = filtered.slice(0, SEARCH_LIMIT).map((doc) => ({
155
+ breadcrumb: doc.breadcrumb ?? [],
156
+ content: doc.content,
157
+ excerpt: highlight(excerptFor(doc.description, doc.content, query), query),
158
+ section: doc.section ?? "",
159
+ title: highlight(doc.title, query),
160
+ url: doc.route,
161
+ }));
162
+ return { hits, sections };
163
+ };
@@ -0,0 +1,60 @@
1
+ import { Client } from "typesense";
2
+
3
+ import { excerptFor, highlight, SEARCH_LIMIT } from "./types.ts";
4
+ import type { SearchFn } from "./types.ts";
5
+
6
+ interface TypesenseRecord extends Record<string, unknown> {
7
+ url: string;
8
+ title: string;
9
+ description?: string;
10
+ content?: string;
11
+ }
12
+
13
+ /**
14
+ * Typesense: the browser queries the collection directly with the search-only
15
+ * key. Documents are imported at build time by the sync step.
16
+ */
17
+ export const createSearch = (opts: {
18
+ collection: string;
19
+ host: string;
20
+ port?: number;
21
+ protocol?: string;
22
+ searchApiKey: string;
23
+ }): SearchFn => {
24
+ const client = new Client({
25
+ apiKey: opts.searchApiKey,
26
+ nodes: [
27
+ {
28
+ host: opts.host,
29
+ port: opts.port ?? 443,
30
+ protocol: opts.protocol ?? "https",
31
+ },
32
+ ],
33
+ });
34
+ return async (query) => {
35
+ const response = await client
36
+ .collections<TypesenseRecord>(opts.collection)
37
+ .documents()
38
+ .search(
39
+ {
40
+ per_page: SEARCH_LIMIT,
41
+ q: query,
42
+ query_by: "title,description,content",
43
+ },
44
+ {}
45
+ );
46
+ const hits = (response.hits ?? []).map((hit) => {
47
+ const doc = hit.document;
48
+ return {
49
+ content: doc.content ?? "",
50
+ excerpt: highlight(
51
+ excerptFor(doc.description ?? "", doc.content ?? "", query),
52
+ query
53
+ ),
54
+ title: highlight(doc.title, query),
55
+ url: doc.url,
56
+ };
57
+ });
58
+ return { hits, sections: [] };
59
+ };
60
+ };