blume 2.1.0 → 2.1.2

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 (450) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/cli/{chunk-ga0pf4aj.js → chunk-0n9xkp3d.js} +12 -3
  3. package/dist/cli/{chunk-ga0pf4aj.js.map → chunk-0n9xkp3d.js.map} +3 -3
  4. package/dist/cli/{chunk-43zcf4gz.js → chunk-0pmb4tr4.js} +541 -368
  5. package/dist/cli/chunk-0pmb4tr4.js.map +35 -0
  6. package/dist/cli/{chunk-9geymqns.js → chunk-0w6hs479.js} +15 -15
  7. package/dist/cli/{chunk-xkz5xbtv.js → chunk-124fvmzq.js} +2802 -1422
  8. package/dist/cli/chunk-124fvmzq.js.map +220 -0
  9. package/dist/cli/chunk-150jk7km.js +52 -0
  10. package/dist/cli/chunk-150jk7km.js.map +10 -0
  11. package/dist/cli/{chunk-y4ykbark.js → chunk-1f882900.js} +2 -2
  12. package/dist/cli/{chunk-5ffywsmb.js → chunk-297ajdcv.js} +8 -6
  13. package/dist/cli/{chunk-5ffywsmb.js.map → chunk-297ajdcv.js.map} +3 -3
  14. package/dist/cli/{chunk-h2a48yhd.js → chunk-32khwcb6.js} +120 -16
  15. package/dist/cli/{chunk-h2a48yhd.js.map → chunk-32khwcb6.js.map} +10 -10
  16. package/dist/cli/{chunk-5cg2rzgf.js → chunk-36bxs9c7.js} +20 -49
  17. package/dist/cli/{chunk-5cg2rzgf.js.map → chunk-36bxs9c7.js.map} +4 -5
  18. package/dist/cli/{chunk-py62fewh.js → chunk-53jqw8z0.js} +3 -3
  19. package/dist/cli/{chunk-5wj2qwn2.js → chunk-58bt3hag.js} +4 -4
  20. package/dist/cli/{chunk-e7vvk5ya.js → chunk-5cmts6dv.js} +3 -3
  21. package/dist/cli/{chunk-y5h4r2y0.js → chunk-60hz265r.js} +15 -5
  22. package/dist/cli/chunk-60hz265r.js.map +10 -0
  23. package/dist/cli/chunk-74wfhv79.js +65 -0
  24. package/dist/cli/chunk-74wfhv79.js.map +10 -0
  25. package/dist/cli/{chunk-g2z9x01v.js → chunk-9qzgg6mj.js} +3 -3
  26. package/dist/cli/{chunk-q3wxxq39.js → chunk-a57yh0h9.js} +5 -5
  27. package/dist/cli/{chunk-713mngdq.js → chunk-ave1wcbr.js} +76 -10
  28. package/dist/cli/chunk-ave1wcbr.js.map +14 -0
  29. package/dist/cli/{chunk-2n4ps1b3.js → chunk-bqxaqn5p.js} +40 -22
  30. package/dist/cli/chunk-bqxaqn5p.js.map +15 -0
  31. package/dist/cli/{chunk-jnwj5kjj.js → chunk-bxwhtvw8.js} +20 -7
  32. package/dist/cli/{chunk-jnwj5kjj.js.map → chunk-bxwhtvw8.js.map} +3 -3
  33. package/dist/cli/chunk-ckrcm08h.js +324 -0
  34. package/dist/cli/chunk-ckrcm08h.js.map +14 -0
  35. package/dist/cli/{chunk-2f3ndkpw.js → chunk-desvm72z.js} +3 -3
  36. package/dist/cli/{chunk-gp84za0h.js → chunk-dhwtqnnd.js} +2 -2
  37. package/dist/cli/{chunk-4h8z9vv5.js → chunk-dpy8qdjf.js} +93 -47
  38. package/dist/cli/chunk-dpy8qdjf.js.map +17 -0
  39. package/dist/cli/{chunk-2f7n4qzt.js → chunk-dqhg54mr.js} +1 -1
  40. package/dist/cli/{chunk-0gz36akr.js → chunk-dv2z0rzf.js} +21 -5
  41. package/dist/cli/chunk-dv2z0rzf.js.map +12 -0
  42. package/dist/cli/{chunk-cm89scdv.js → chunk-es8h0v3j.js} +9 -11
  43. package/dist/cli/{chunk-cm89scdv.js.map → chunk-es8h0v3j.js.map} +3 -3
  44. package/dist/cli/{chunk-zhvd6adm.js → chunk-gfc5fqvf.js} +14 -14
  45. package/dist/cli/{chunk-nd1821sk.js → chunk-gxtg6ed2.js} +33 -19
  46. package/dist/cli/chunk-gxtg6ed2.js.map +10 -0
  47. package/dist/cli/{chunk-p804f3pe.js → chunk-hvcxng5f.js} +6 -6
  48. package/dist/cli/{chunk-ta27feev.js → chunk-j1x92mx9.js} +21 -2
  49. package/dist/cli/chunk-j1x92mx9.js.map +10 -0
  50. package/dist/cli/{chunk-rekqz4qg.js → chunk-k3gr1nm9.js} +19 -16
  51. package/dist/cli/chunk-k3gr1nm9.js.map +10 -0
  52. package/dist/cli/{chunk-1n228xqb.js → chunk-kdpzw38a.js} +2 -2
  53. package/dist/cli/{chunk-cy5bm0j0.js → chunk-ms9z66nz.js} +2 -2
  54. package/dist/cli/{chunk-cy5bm0j0.js.map → chunk-ms9z66nz.js.map} +2 -2
  55. package/dist/cli/{chunk-zf748avv.js → chunk-nbyvgnnm.js} +193 -286
  56. package/dist/cli/chunk-nbyvgnnm.js.map +36 -0
  57. package/dist/cli/{chunk-6bm0xpgd.js → chunk-s0bdnc7v.js} +2 -2
  58. package/dist/cli/{chunk-yxmk4pec.js → chunk-sc93apfs.js} +17 -16
  59. package/dist/cli/{chunk-yxmk4pec.js.map → chunk-sc93apfs.js.map} +3 -3
  60. package/dist/cli/{chunk-cmtcnh9f.js → chunk-tfycddbt.js} +2 -2
  61. package/dist/cli/{chunk-9xr4dgee.js → chunk-twy7psdz.js} +29 -16
  62. package/dist/cli/chunk-twy7psdz.js.map +10 -0
  63. package/dist/cli/{chunk-ntv4qrt7.js → chunk-y4msrwm9.js} +15 -15
  64. package/dist/cli/{chunk-yj17vs39.js → chunk-ydp3065p.js} +5 -5
  65. package/dist/cli/index.js +24 -19
  66. package/dist/cli/index.js.map +3 -3
  67. package/dist/types/ai/ask-context.d.ts +6 -2
  68. package/dist/types/ai/ask.d.ts +24 -5
  69. package/dist/types/ai/component-markdown.d.ts +2 -0
  70. package/dist/types/ai/openapi-components.d.ts +4 -1
  71. package/dist/types/analytics/clics.d.ts +37 -0
  72. package/dist/types/analytics/index.d.ts +2 -0
  73. package/dist/types/analytics/schema.d.ts +18 -0
  74. package/dist/types/astro/integration.d.ts +11 -4
  75. package/dist/types/astro/markdown-negotiation.d.ts +3 -0
  76. package/dist/types/astro/pages.d.ts +10 -0
  77. package/dist/types/cli/init/scaffold.d.ts +1 -1
  78. package/dist/types/components/content/api-field.d.ts +4 -1
  79. package/dist/types/components/content/api-page.d.ts +6 -1
  80. package/dist/types/components/openapi/code-samples.d.ts +29 -0
  81. package/dist/types/components/openapi/helpers.d.ts +22 -1
  82. package/dist/types/components/openapi/security.d.ts +10 -0
  83. package/dist/types/components/openapi/snippets.d.ts +60 -0
  84. package/dist/types/core/changelog-index.d.ts +8 -0
  85. package/dist/types/core/config-input.d.ts +37 -6
  86. package/dist/types/core/data.d.ts +4 -0
  87. package/dist/types/core/i18n-ui.d.ts +2 -0
  88. package/dist/types/core/links.d.ts +8 -0
  89. package/dist/types/core/powered-by.d.ts +12 -0
  90. package/dist/types/core/probe.d.ts +2 -0
  91. package/dist/types/core/project-graph.d.ts +6 -0
  92. package/dist/types/core/redirect-patterns.d.ts +11 -3
  93. package/dist/types/core/schema.d.ts +228 -3
  94. package/dist/types/core/sources/cache.d.ts +3 -0
  95. package/dist/types/core/sources/contentful-rich-text.d.ts +11 -2
  96. package/dist/types/core/sources/contentful.d.ts +17 -1
  97. package/dist/types/core/sources/lexical.d.ts +5 -0
  98. package/dist/types/core/sources/lower.d.ts +10 -2
  99. package/dist/types/core/sources/normalize.d.ts +20 -1
  100. package/dist/types/core/sources/notion.d.ts +8 -0
  101. package/dist/types/core/sources/remote.d.ts +37 -10
  102. package/dist/types/core/sources/sanity.d.ts +1 -1
  103. package/dist/types/core/types.d.ts +17 -0
  104. package/dist/types/deploy/adapter-output.d.ts +2 -1
  105. package/dist/types/deploy/adapters/cloudflare.d.ts +7 -5
  106. package/dist/types/deploy/adapters/node.d.ts +58 -23
  107. package/dist/types/deploy/adapters/registry.d.ts +10 -0
  108. package/dist/types/deploy/adapters/types.d.ts +3 -2
  109. package/dist/types/deploy/cloudflare-negotiation.d.ts +5 -2
  110. package/dist/types/deploy/node-headers.d.ts +12 -4
  111. package/dist/types/deploy/platforms/cloudflare.d.ts +10 -7
  112. package/dist/types/deploy/platforms/node.d.ts +3 -1
  113. package/dist/types/deploy/platforms/types.d.ts +18 -7
  114. package/dist/types/deploy/platforms/vercel.d.ts +21 -2
  115. package/dist/types/deploy/redirects.d.ts +17 -1
  116. package/dist/types/deploy/sitemap.d.ts +2 -1
  117. package/dist/types/deploy/vercel-negotiation.d.ts +44 -21
  118. package/dist/types/narration/build.d.ts +34 -12
  119. package/dist/types/narration/provider.d.ts +96 -8
  120. package/dist/types/openapi/checks.d.ts +27 -0
  121. package/dist/types/openapi/graphql.d.ts +6 -0
  122. package/dist/types/openapi/model.d.ts +15 -4
  123. package/dist/types/openapi/parse.d.ts +3 -0
  124. package/dist/types/openapi/references.d.ts +16 -0
  125. package/dist/types/openapi/sentence.d.ts +11 -1
  126. package/dist/types/ratelimit/index.d.ts +1 -1
  127. package/dist/types/ratelimit/schema.d.ts +8 -0
  128. package/dist/types/ratelimit/upstash.d.ts +39 -14
  129. package/dist/types/reference/graphql.d.ts +76 -1
  130. package/dist/types/reference/index.d.ts +1 -1
  131. package/dist/types/reference/openapi.d.ts +2 -2
  132. package/dist/types/reference/options.d.ts +37 -1
  133. package/dist/types/reference/schema.d.ts +84 -0
  134. package/dist/types/search/adapters/registry.d.ts +3 -0
  135. package/dist/types/search/adapters/typesense.d.ts +14 -4
  136. package/dist/types/search/build.d.ts +3 -1
  137. package/dist/types/search/orama-index.d.ts +9 -9
  138. package/dist/types/search/sync/algolia.d.ts +5 -2
  139. package/dist/types/search/sync/index.d.ts +3 -1
  140. package/dist/types/search/sync/typesense.d.ts +11 -7
  141. package/dist/types/seo/jsonld.d.ts +11 -6
  142. package/dist/types/sources/contentful.d.ts +16 -0
  143. package/dist/types/sources/github-releases.d.ts +8 -1
  144. package/dist/types/sources/notion.d.ts +8 -0
  145. package/dist/types/sources/payload.d.ts +2 -0
  146. package/dist/types/sources/registry.d.ts +63 -0
  147. package/dist/types/sources/shared.d.ts +3 -0
  148. package/dist/types/sources/strapi.d.ts +2 -0
  149. package/docs/01-quickstart.mdx +4 -2
  150. package/docs/02-deployment.mdx +59 -13
  151. package/docs/03-upgrading.mdx +2 -1
  152. package/docs/04-migrating.mdx +9 -9
  153. package/docs/08-faq.mdx +12 -12
  154. package/docs/advanced/changelog.mdx +30 -2
  155. package/docs/advanced/custom-pages.mdx +1 -1
  156. package/docs/advanced/skills.mdx +1 -1
  157. package/docs/cli/audit.mdx +30 -7
  158. package/docs/cli/doctor.mdx +6 -2
  159. package/docs/cli/evals.mdx +4 -4
  160. package/docs/cli/translate.mdx +1 -0
  161. package/docs/cli/validate.mdx +4 -3
  162. package/docs/configuration/analytics.mdx +19 -1
  163. package/docs/configuration/assistant.mdx +6 -2
  164. package/docs/configuration/customization.mdx +4 -2
  165. package/docs/configuration/export.mdx +5 -3
  166. package/docs/configuration/index.mdx +4 -2
  167. package/docs/configuration/narration.mdx +43 -6
  168. package/docs/configuration/rate-limiting.mdx +23 -2
  169. package/docs/configuration/search.mdx +37 -14
  170. package/docs/content/components.mdx +2 -2
  171. package/docs/content/frontmatter.mdx +5 -3
  172. package/docs/content/i18n.mdx +2 -2
  173. package/docs/content/includes.mdx +1 -1
  174. package/docs/content/navigation.mdx +8 -6
  175. package/docs/content/sources/contentful.mdx +9 -4
  176. package/docs/content/sources/custom.mdx +7 -3
  177. package/docs/content/sources/github-releases.mdx +12 -2
  178. package/docs/content/sources/index.mdx +3 -3
  179. package/docs/content/sources/notion.mdx +17 -2
  180. package/docs/content/sources/obsidian.mdx +3 -1
  181. package/docs/content/sources/payload.mdx +5 -2
  182. package/docs/content/sources/remote-mdx.mdx +6 -0
  183. package/docs/content/sources/sanity.mdx +4 -2
  184. package/docs/content/sources/strapi.mdx +5 -2
  185. package/docs/content/syntax.mdx +2 -2
  186. package/docs/discoverability/agent-discovery.mdx +1 -1
  187. package/docs/discoverability/index.mdx +1 -1
  188. package/docs/discoverability/json-api.mdx +2 -0
  189. package/docs/discoverability/llms-txt.mdx +1 -1
  190. package/docs/discoverability/markdown.mdx +3 -1
  191. package/docs/discoverability/mcp.mdx +6 -0
  192. package/docs/discoverability/sitemap-and-robots.mdx +1 -1
  193. package/docs/discoverability/structured-data.mdx +8 -6
  194. package/docs/references/api-pages.mdx +1 -1
  195. package/docs/references/asyncapi.mdx +5 -1
  196. package/docs/references/graphql.mdx +15 -1
  197. package/docs/references/openapi.mdx +64 -8
  198. package/package.json +9 -9
  199. package/skills/blume/SKILL.md +1 -1
  200. package/skills/blume-migrate/SKILL.md +2 -2
  201. package/skills/blume-migrate/assets/{oxfmt@0.67.0.patch → oxfmt@0.71.0.patch} +7 -7
  202. package/skills/blume-migrate/references/docusaurus.md +4 -4
  203. package/skills/blume-migrate/references/fumadocs.md +17 -13
  204. package/skills/blume-migrate/references/monorepo.md +5 -5
  205. package/skills/blume-migrate/references/nextra.md +8 -5
  206. package/skills/blume-migrate/references/starlight.md +9 -7
  207. package/src/ai/ask-context.ts +33 -3
  208. package/src/ai/ask-limits.ts +13 -0
  209. package/src/ai/ask.ts +24 -6
  210. package/src/ai/changelog-markdown.ts +11 -6
  211. package/src/ai/component-markdown.ts +1 -1
  212. package/src/ai/cors.ts +12 -3
  213. package/src/ai/mcp/query.ts +14 -6
  214. package/src/ai/openapi-components.ts +28 -4
  215. package/src/ai/relative-links.ts +4 -38
  216. package/src/analytics/clics.ts +58 -0
  217. package/src/analytics/head.ts +5 -0
  218. package/src/analytics/index.ts +2 -0
  219. package/src/analytics/schema.ts +2 -0
  220. package/src/astro/generate.ts +91 -56
  221. package/src/astro/integration.ts +72 -22
  222. package/src/astro/markdown-negotiation.ts +3 -0
  223. package/src/astro/module-types.ts +3 -1
  224. package/src/astro/pages.ts +23 -0
  225. package/src/astro/render-deps.ts +10 -5
  226. package/src/astro/runtime-deps.ts +23 -2
  227. package/src/astro/templates.ts +129 -81
  228. package/src/audit/catalog.ts +19 -3
  229. package/src/audit/checks/links.ts +14 -1
  230. package/src/audit/checks/network.ts +110 -6
  231. package/src/audit/checks/redirects.ts +6 -4
  232. package/src/audit/checks/robots.ts +58 -2
  233. package/src/audit/run.ts +3 -0
  234. package/src/audit/types.ts +2 -0
  235. package/src/audit/url.ts +14 -5
  236. package/src/blume-modules.d.ts +5 -1
  237. package/src/cli/args.ts +51 -0
  238. package/src/cli/build-failure.ts +23 -0
  239. package/src/cli/commands/audit.ts +10 -1
  240. package/src/cli/commands/dev.ts +18 -2
  241. package/src/cli/commands/doctor.ts +45 -12
  242. package/src/cli/commands/eval.ts +36 -4
  243. package/src/cli/commands/init.ts +6 -4
  244. package/src/cli/commands/validate.ts +10 -1
  245. package/src/cli/init/scaffold.ts +9 -6
  246. package/src/cli/prepare.ts +6 -3
  247. package/src/cli/required-secrets.ts +3 -2
  248. package/src/components/content/ApiPlayground.astro +6 -2
  249. package/src/components/content/api-field.ts +4 -1
  250. package/src/components/content/api-page.ts +31 -7
  251. package/src/components/content/mermaid-element.ts +15 -1
  252. package/src/components/content/view-switcher.ts +7 -4
  253. package/src/components/islands/hooks.ts +27 -1
  254. package/src/components/layout/NavTree.astro +49 -5
  255. package/src/components/layout/NavTreeScript.astro +18 -0
  256. package/src/components/layout/PageActions.astro +139 -24
  257. package/src/components/layout/PageLayout.astro +13 -3
  258. package/src/components/layout/RootLayout.astro +27 -14
  259. package/src/components/layout/Search.astro +17 -3
  260. package/src/components/layout/TableOfContents.astro +24 -17
  261. package/src/components/layout/analytics-client.ts +8 -8
  262. package/src/components/layout/head-scripts.ts +6 -1
  263. package/src/components/layout/search/algolia.ts +10 -1
  264. package/src/components/layout/search/analytics.ts +42 -14
  265. package/src/components/layout/search/endpoint.ts +48 -5
  266. package/src/components/layout/search/pagefind.ts +114 -5
  267. package/src/components/layout/search/types.ts +12 -0
  268. package/src/components/layout/search/typesense.ts +18 -6
  269. package/src/components/openapi/AsyncApiOperation.astro +14 -7
  270. package/src/components/openapi/Authorization.astro +10 -0
  271. package/src/components/openapi/GraphqlOperation.astro +5 -0
  272. package/src/components/openapi/Operation.astro +24 -1
  273. package/src/components/openapi/ParametersTable.astro +8 -4
  274. package/src/components/openapi/Playground.astro +7 -1
  275. package/src/components/openapi/RequestBody.astro +19 -2
  276. package/src/components/openapi/Responses.astro +1 -0
  277. package/src/components/openapi/SchemaTable.astro +15 -3
  278. package/src/components/openapi/async-snippets.ts +61 -5
  279. package/src/components/openapi/async.ts +123 -29
  280. package/src/components/openapi/graphql-helpers.ts +35 -12
  281. package/src/components/openapi/helpers.ts +36 -2
  282. package/src/components/openapi/message-model.ts +54 -11
  283. package/src/components/openapi/message.ts +14 -4
  284. package/src/components/openapi/operation-model.ts +16 -7
  285. package/src/components/openapi/playground-client.ts +77 -58
  286. package/src/components/openapi/playground-schema.ts +1 -1
  287. package/src/components/openapi/schema-tree.ts +44 -14
  288. package/src/components/openapi/security.ts +10 -0
  289. package/src/components/openapi/snippets.ts +5 -3
  290. package/src/components/openapi/validate-json.ts +4 -3
  291. package/src/components/powered-by-middleware.ts +33 -0
  292. package/src/core/changelog-index.ts +31 -1
  293. package/src/core/component-diagnostics.ts +53 -0
  294. package/src/core/component-overrides.ts +32 -0
  295. package/src/core/config-input.ts +42 -6
  296. package/src/core/data.ts +2 -0
  297. package/src/core/diagnostics.ts +5 -0
  298. package/src/core/i18n-ui.ts +2 -0
  299. package/src/core/includes.ts +1 -1
  300. package/src/core/links.ts +47 -17
  301. package/src/core/powered-by.ts +12 -0
  302. package/src/core/probe.ts +26 -6
  303. package/src/core/project-graph.ts +59 -18
  304. package/src/core/redirect-patterns.ts +36 -7
  305. package/src/core/schema.ts +26 -6
  306. package/src/core/sources/cache.ts +6 -0
  307. package/src/core/sources/contentful-rich-text.ts +29 -6
  308. package/src/core/sources/contentful.ts +135 -25
  309. package/src/core/sources/filesystem.ts +26 -4
  310. package/src/core/sources/github-releases.ts +12 -1
  311. package/src/core/sources/lexical.ts +15 -10
  312. package/src/core/sources/lower.ts +19 -3
  313. package/src/core/sources/mdx-remote.ts +62 -10
  314. package/src/core/sources/normalize.ts +227 -41
  315. package/src/core/sources/notion.ts +34 -10
  316. package/src/core/sources/obsidian.ts +16 -3
  317. package/src/core/sources/payload.ts +38 -15
  318. package/src/core/sources/remote.ts +72 -15
  319. package/src/core/sources/resolve.ts +11 -1
  320. package/src/core/sources/sanity.ts +43 -25
  321. package/src/core/sources/strapi.ts +2 -2
  322. package/src/core/types.ts +18 -0
  323. package/src/core/ui-packs/ar.ts +1 -0
  324. package/src/core/ui-packs/bg.ts +1 -0
  325. package/src/core/ui-packs/bn.ts +1 -0
  326. package/src/core/ui-packs/ca.ts +1 -0
  327. package/src/core/ui-packs/cs.ts +1 -0
  328. package/src/core/ui-packs/da.ts +1 -0
  329. package/src/core/ui-packs/de.ts +1 -0
  330. package/src/core/ui-packs/el.ts +1 -0
  331. package/src/core/ui-packs/es.ts +1 -0
  332. package/src/core/ui-packs/fa.ts +1 -0
  333. package/src/core/ui-packs/fi.ts +1 -0
  334. package/src/core/ui-packs/fr.ts +1 -0
  335. package/src/core/ui-packs/he.ts +1 -0
  336. package/src/core/ui-packs/hi.ts +1 -0
  337. package/src/core/ui-packs/hr.ts +1 -0
  338. package/src/core/ui-packs/hu.ts +1 -0
  339. package/src/core/ui-packs/id.ts +1 -0
  340. package/src/core/ui-packs/it.ts +1 -0
  341. package/src/core/ui-packs/ja.ts +1 -0
  342. package/src/core/ui-packs/ko.ts +1 -0
  343. package/src/core/ui-packs/nl.ts +1 -0
  344. package/src/core/ui-packs/no.ts +1 -0
  345. package/src/core/ui-packs/pl.ts +1 -0
  346. package/src/core/ui-packs/pt-br.ts +1 -0
  347. package/src/core/ui-packs/pt.ts +1 -0
  348. package/src/core/ui-packs/ro.ts +1 -0
  349. package/src/core/ui-packs/ru.ts +1 -0
  350. package/src/core/ui-packs/sk.ts +1 -0
  351. package/src/core/ui-packs/sr.ts +1 -0
  352. package/src/core/ui-packs/sv.ts +1 -0
  353. package/src/core/ui-packs/th.ts +1 -0
  354. package/src/core/ui-packs/tr.ts +1 -0
  355. package/src/core/ui-packs/uk.ts +1 -0
  356. package/src/core/ui-packs/vi.ts +1 -0
  357. package/src/core/ui-packs/zh-tw.ts +1 -0
  358. package/src/core/ui-packs/zh.ts +1 -0
  359. package/src/deploy/adapter-output.ts +7 -2
  360. package/src/deploy/adapters/cloudflare.ts +7 -5
  361. package/src/deploy/adapters/node.ts +47 -9
  362. package/src/deploy/adapters/types.ts +7 -3
  363. package/src/deploy/artifacts.ts +2 -1
  364. package/src/deploy/cloudflare-negotiation.ts +36 -7
  365. package/src/deploy/headers.ts +15 -1
  366. package/src/deploy/node-headers.ts +32 -20
  367. package/src/deploy/platforms/cloudflare.ts +14 -9
  368. package/src/deploy/platforms/netlify.ts +2 -1
  369. package/src/deploy/platforms/node.ts +12 -2
  370. package/src/deploy/platforms/types.ts +21 -7
  371. package/src/deploy/platforms/vercel.ts +115 -17
  372. package/src/deploy/redirects.ts +66 -6
  373. package/src/deploy/rss.ts +3 -1
  374. package/src/deploy/sitemap.ts +39 -5
  375. package/src/deploy/vercel-negotiation.ts +325 -103
  376. package/src/eval/agents.ts +18 -1
  377. package/src/eval/prompts.ts +6 -3
  378. package/src/eval/report.ts +34 -13
  379. package/src/eval/run.ts +5 -0
  380. package/src/markdown/base-links.ts +15 -14
  381. package/src/markdown/package-commands.ts +9 -8
  382. package/src/markdown/relative-links.ts +3 -2
  383. package/src/narration/build.ts +218 -40
  384. package/src/narration/provider.ts +89 -9
  385. package/src/openapi/checks.ts +273 -0
  386. package/src/openapi/graphql-build.ts +21 -0
  387. package/src/openapi/graphql.ts +11 -0
  388. package/src/openapi/model.ts +15 -3
  389. package/src/openapi/parse.ts +127 -2
  390. package/src/openapi/references.ts +43 -3
  391. package/src/openapi/render-mdx.ts +68 -8
  392. package/src/openapi/sentence.ts +29 -2
  393. package/src/openapi/source.ts +39 -14
  394. package/src/ratelimit/index.ts +1 -1
  395. package/src/ratelimit/runtime.ts +37 -19
  396. package/src/ratelimit/upstash.ts +40 -10
  397. package/src/reference/graphql.ts +15 -1
  398. package/src/reference/index.ts +1 -0
  399. package/src/reference/openapi.ts +2 -2
  400. package/src/reference/options.ts +29 -1
  401. package/src/registry/eject.ts +8 -2
  402. package/src/search/adapters/locale-scope.ts +30 -0
  403. package/src/search/adapters/typesense.ts +14 -4
  404. package/src/search/build.ts +21 -3
  405. package/src/search/orama-index.ts +163 -31
  406. package/src/search/source-pages.ts +29 -0
  407. package/src/search/sync/algolia.ts +99 -9
  408. package/src/search/sync/index.ts +52 -13
  409. package/src/search/sync/typesense.ts +72 -27
  410. package/src/seo/jsonld.ts +38 -20
  411. package/src/sources/contentful.ts +12 -0
  412. package/src/sources/github-releases.ts +7 -1
  413. package/src/sources/notion.ts +7 -0
  414. package/src/sources/shared.ts +3 -0
  415. package/src/theme/color.ts +9 -10
  416. package/src/theme/entry.ts +43 -6
  417. package/src/translate/validate.ts +23 -1
  418. package/dist/cli/chunk-0gz36akr.js.map +0 -12
  419. package/dist/cli/chunk-2n4ps1b3.js.map +0 -15
  420. package/dist/cli/chunk-43zcf4gz.js.map +0 -36
  421. package/dist/cli/chunk-4ejxbzee.js +0 -39
  422. package/dist/cli/chunk-4ejxbzee.js.map +0 -10
  423. package/dist/cli/chunk-4h8z9vv5.js.map +0 -16
  424. package/dist/cli/chunk-713mngdq.js.map +0 -14
  425. package/dist/cli/chunk-9xr4dgee.js.map +0 -10
  426. package/dist/cli/chunk-amrb6hgh.js +0 -145
  427. package/dist/cli/chunk-amrb6hgh.js.map +0 -11
  428. package/dist/cli/chunk-nd1821sk.js.map +0 -10
  429. package/dist/cli/chunk-rekqz4qg.js.map +0 -10
  430. package/dist/cli/chunk-ta27feev.js.map +0 -10
  431. package/dist/cli/chunk-xkz5xbtv.js.map +0 -213
  432. package/dist/cli/chunk-y5h4r2y0.js.map +0 -10
  433. package/dist/cli/chunk-zf748avv.js.map +0 -37
  434. /package/dist/cli/{chunk-9geymqns.js.map → chunk-0w6hs479.js.map} +0 -0
  435. /package/dist/cli/{chunk-y4ykbark.js.map → chunk-1f882900.js.map} +0 -0
  436. /package/dist/cli/{chunk-py62fewh.js.map → chunk-53jqw8z0.js.map} +0 -0
  437. /package/dist/cli/{chunk-5wj2qwn2.js.map → chunk-58bt3hag.js.map} +0 -0
  438. /package/dist/cli/{chunk-e7vvk5ya.js.map → chunk-5cmts6dv.js.map} +0 -0
  439. /package/dist/cli/{chunk-g2z9x01v.js.map → chunk-9qzgg6mj.js.map} +0 -0
  440. /package/dist/cli/{chunk-q3wxxq39.js.map → chunk-a57yh0h9.js.map} +0 -0
  441. /package/dist/cli/{chunk-2f3ndkpw.js.map → chunk-desvm72z.js.map} +0 -0
  442. /package/dist/cli/{chunk-gp84za0h.js.map → chunk-dhwtqnnd.js.map} +0 -0
  443. /package/dist/cli/{chunk-2f7n4qzt.js.map → chunk-dqhg54mr.js.map} +0 -0
  444. /package/dist/cli/{chunk-zhvd6adm.js.map → chunk-gfc5fqvf.js.map} +0 -0
  445. /package/dist/cli/{chunk-p804f3pe.js.map → chunk-hvcxng5f.js.map} +0 -0
  446. /package/dist/cli/{chunk-1n228xqb.js.map → chunk-kdpzw38a.js.map} +0 -0
  447. /package/dist/cli/{chunk-6bm0xpgd.js.map → chunk-s0bdnc7v.js.map} +0 -0
  448. /package/dist/cli/{chunk-cmtcnh9f.js.map → chunk-tfycddbt.js.map} +0 -0
  449. /package/dist/cli/{chunk-ntv4qrt7.js.map → chunk-y4msrwm9.js.map} +0 -0
  450. /package/dist/cli/{chunk-yj17vs39.js.map → chunk-ydp3065p.js.map} +0 -0
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import { AUTH_METHODS } from "../components/content/api-page.ts";
4
+ import type { AuthMethod } from "../components/content/api-page.ts";
3
5
  import { unrecognizedKeysMessage } from "../core/unrecognized-keys.ts";
4
6
 
5
7
  /**
@@ -99,18 +101,44 @@ export interface OpenApiSourceOptions extends ReferenceSourceOptions {
99
101
  overlays?: string[];
100
102
  }
101
103
 
104
+ /**
105
+ * How a GraphQL endpoint authenticates, in the shape the site's `api.auth`
106
+ * takes for hand-written endpoints: `bearer`, `basic`, or `key` (sent in the
107
+ * `name` header, `x-api-key` by default). A schema, unlike an OpenAPI
108
+ * document, declares no security schemes, so this is what gives the Try it
109
+ * panel its credential field.
110
+ */
111
+ export const graphqlAuthSchema = z.strictObject({
112
+ method: z.enum(AUTH_METHODS),
113
+ /** The header an API key goes in. Defaults to `x-api-key`. */
114
+ name: z.string().optional(),
115
+ });
116
+
117
+ /** The `auth` option of `graphql()` and its sources. */
118
+ export interface GraphqlAuthOptions {
119
+ /** `bearer`, `basic`, `key`, or `none`. */
120
+ method: AuthMethod;
121
+ /** The header an API key goes in. Defaults to `x-api-key`. */
122
+ name?: string;
123
+ }
124
+
102
125
  /**
103
126
  * A single GraphQL schema: the shared source shape plus `endpoint`, the live
104
127
  * GraphQL API URL the playground and code samples target (a schema, unlike an
105
- * OpenAPI document, names no server).
128
+ * OpenAPI document, names no server), and `auth`, how that endpoint
129
+ * authenticates.
106
130
  */
107
131
  export const graphqlSourceSchema = referenceSourceSchema.extend({
132
+ /** How the endpoint authenticates (playground + code samples). */
133
+ auth: graphqlAuthSchema.optional(),
108
134
  /** URL of the live GraphQL endpoint (playground + code samples). */
109
135
  endpoint: z.string().optional(),
110
136
  });
111
137
 
112
138
  /** One schema source, as `graphql()` accepts it. */
113
139
  export interface GraphqlSourceOptions extends ReferenceSourceOptions {
140
+ /** How the endpoint authenticates; wins over the adapter's `auth`. */
141
+ auth?: GraphqlAuthOptions;
114
142
  /** URL of the live GraphQL endpoint (playground + code samples); wins over the adapter's `endpoint`. */
115
143
  endpoint?: string;
116
144
  }
@@ -88,6 +88,7 @@ import {
88
88
  import { buildReferenceFiles } from "../openapi/scalar.ts";
89
89
  import { isOpenApiSource } from "../openapi/source.ts";
90
90
  import { buildSearchDocuments } from "../search/documents.ts";
91
+ import { sourcePages } from "../search/source-pages.ts";
91
92
  import {
92
93
  examplesEntryTemplate,
93
94
  tailwindEntryTemplate,
@@ -528,12 +529,15 @@ const examplesPreviewFiles = (
528
529
  * eject declares the ones its generated files import directly: the AI SDK the
529
530
  * assistant route streams through (unless `ai.assistant.endpoint` points
530
531
  * elsewhere, when no route is written) and the EPUB generator's browser bundle
531
- * `features.ts` loads (only named there when `export.epub` is on).
532
+ * `features.ts` loads (only named there when `export.epub` is on). The
533
+ * narration provider's SDK (`openai()`'s `@ai-sdk/openai`) is no runtime
534
+ * import, but the ejected app's `astro build` loads it to generate the clips.
532
535
  */
533
536
  const ejectDependencies = (
534
537
  options: Parameters<typeof runtimeDependencies>[0]
535
538
  ): string[] => {
536
539
  const { assistant } = options.config.ai;
540
+ const { narration } = options.config;
537
541
  return [
538
542
  ...new Set([
539
543
  "astro",
@@ -544,6 +548,7 @@ const ejectDependencies = (
544
548
  ...runtimeDependencies(options),
545
549
  ...(options.needsReact ? ["react", "react-dom"] : []),
546
550
  ...(assistant?.enabled && !assistant.endpoint ? ["ai"] : []),
551
+ ...(narration.enabled ? (narration.provider?.runtimeDeps ?? []) : []),
547
552
  ...(options.config.export.epub ? ["epub-gen-memory"] : []),
548
553
  ]),
549
554
  ];
@@ -755,7 +760,7 @@ export const eject = async (
755
760
  // the package is hoisted out of the project's own node_modules.
756
761
  sources: [
757
762
  blumeSourceGlob(root, genDir),
758
- "../../**/*.{astro,mdx,ts,tsx}",
763
+ "../../**/*.{astro,jsx,mdx,ts,tsx}",
759
764
  ],
760
765
  twoslashCss: twoslashCss(),
761
766
  userTheme,
@@ -914,6 +919,7 @@ export const eject = async (
914
919
  files.push({
915
920
  content: mixedbreadSearchEndpointTemplate(
916
921
  searchAdapter.options,
922
+ sourcePages(project),
917
923
  config.rateLimit
918
924
  ),
919
925
  path: join(srcDir, "pages", "api", "search.ts"),
@@ -0,0 +1,30 @@
1
+ import type { SearchAdapterKind } from "./registry.ts";
2
+
3
+ /**
4
+ * Whether each adapter's client can limit results to one language. The
5
+ * source-built indexes (Orama, FlexSearch) store every document's `locale`,
6
+ * the Algolia, Orama Cloud, and Typesense syncs upload it for their clients
7
+ * to filter on, and Pagefind keeps an index per language, merging the others
8
+ * in to search them all. The Mixedbread endpoint takes no filter, so it
9
+ * always searches every language. Keyed by every kind so a new adapter has
10
+ * to decide.
11
+ */
12
+ const SCOPES_LOCALES = {
13
+ algolia: true,
14
+ flexsearch: true,
15
+ mixedbread: false,
16
+ none: false,
17
+ orama: true,
18
+ "orama-cloud": true,
19
+ pagefind: true,
20
+ typesense: true,
21
+ } satisfies Record<SearchAdapterKind, boolean>;
22
+
23
+ /**
24
+ * Whether the configured search adapter scopes results to the page's
25
+ * language. The search dialog offers its "All languages" toggle only when it
26
+ * does: on the others the toggle would change nothing, and every query
27
+ * already spans all languages.
28
+ */
29
+ export const scopesLocales = (kind: SearchAdapterKind): boolean =>
30
+ SCOPES_LOCALES[kind];
@@ -6,14 +6,22 @@ import type { SearchAdapter } from "./types.ts";
6
6
  /**
7
7
  * Connection details for a self-hosted or cloud Typesense node. The browser
8
8
  * queries the collection with the search-only `apiKey`; the build-time sync
9
- * recreates the collection with `TYPESENSE_ADMIN_API_KEY`, which never enters
10
- * the config.
9
+ * rebuilds it with `TYPESENSE_ADMIN_API_KEY`, which never enters the config.
11
10
  */
12
11
  export interface TypesenseNamedOptions {
13
12
  /** The search-only API key (public — it ships to the browser). */
14
13
  apiKey: string;
14
+ /** The alias the sync points at each new collection, and searches read. */
15
15
  collection: string;
16
16
  host: string;
17
+ /**
18
+ * The language Typesense tokenizes the text fields for, as a two-letter
19
+ * code (`ja`, `zh`, `ko`, `th`, …). Typesense's default tokenizer splits on
20
+ * spaces, so text in a script written without them — Chinese, Japanese,
21
+ * Thai — only matches from the start of a run. Omitted, the fields keep
22
+ * that default.
23
+ */
24
+ locale?: string;
17
25
  /** Defaults to 443. */
18
26
  port?: number;
19
27
  /** Defaults to `https`. */
@@ -39,14 +47,16 @@ export const typesenseOptionsSchema = z
39
47
  apiKey: z.string(),
40
48
  collection: z.string(),
41
49
  host: z.string(),
50
+ locale: z.string().optional(),
42
51
  port: z.number().int().positive().optional(),
43
52
  protocol: z.enum(["http", "https"]).optional(),
44
53
  })
45
54
  .catchall(z.json());
46
55
 
47
56
  /**
48
- * Hosted search on Typesense. Each `blume build` drops and recreates the
49
- * collection so deleted or renamed pages don't linger as stale hits.
57
+ * Hosted search on Typesense. Each `blume build` imports into a new
58
+ * collection and swaps the `collection` alias onto it, so deleted or renamed
59
+ * pages don't linger as stale hits and searches never read a partial index.
50
60
  */
51
61
  export const typesense = (options: TypesenseOptions): TypesenseAdapter => ({
52
62
  kind: "typesense",
@@ -3,6 +3,11 @@ import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { close, createIndex } from "pagefind";
4
4
  import { dirname, join } from "pathe";
5
5
 
6
+ /** The part of `pagefind-entry.json` that counts the index's pages. */
7
+ interface PagefindEntry {
8
+ languages: Record<string, { page_count: number }>;
9
+ }
10
+
6
11
  /**
7
12
  * Build a local Pagefind search index over the built site. Pagefind indexes
8
13
  * every rendered page except those whose `<html>` carries
@@ -23,7 +28,9 @@ import { dirname, join } from "pathe";
23
28
  * Pagefind/pagefind#1272; switch back to `writeFiles()` once a release ships
24
29
  * that fix.
25
30
  *
26
- * Returns the number of pages indexed.
31
+ * Returns the number of pages indexed: the index's own count, summed over its
32
+ * languages from `pagefind-entry.json`. `addDirectory`'s `page_count` counts
33
+ * every HTML file it read, including the ones kept out of the index.
27
34
  */
28
35
  export const buildSearchIndex = async (outDir: string): Promise<number> => {
29
36
  const { index } = await createIndex({});
@@ -34,7 +41,7 @@ export const buildSearchIndex = async (outDir: string): Promise<number> => {
34
41
  // These awaits are strictly ordered, not independent: the directory must be
35
42
  // indexed before its files are read, and the index closed only after.
36
43
  // oxlint-disable-next-line react-doctor/async-parallel
37
- const result = await index.addDirectory({ path: outDir });
44
+ await index.addDirectory({ path: outDir });
38
45
  const { files } = await index.getFiles();
39
46
  await close();
40
47
 
@@ -49,5 +56,16 @@ export const buildSearchIndex = async (outDir: string): Promise<number> => {
49
56
  files.map((file) => writeFile(join(searchDir, file.path), file.content))
50
57
  );
51
58
 
52
- return result.page_count;
59
+ // Pagefind always writes the entry file, listing no languages when nothing
60
+ // was indexed.
61
+ const entry = files.find((file) => file.path === "pagefind-entry.json");
62
+ // SAFETY: the entry file is Pagefind's JSON, which counts each language's
63
+ // pages in `page_count`.
64
+ const { languages } = JSON.parse(
65
+ new TextDecoder().decode(entry?.content)
66
+ ) as PagefindEntry;
67
+ return Object.values(languages).reduce(
68
+ (sum, language) => sum + language.page_count,
69
+ 0
70
+ );
53
71
  };
@@ -1,8 +1,16 @@
1
- import { create, insertMultiple, search } from "@orama/orama";
1
+ import {
2
+ components,
3
+ create,
4
+ insert,
5
+ insertMultiple,
6
+ search,
7
+ } from "@orama/orama";
2
8
  import type {
3
9
  AnyOrama,
4
10
  EnumArrComparisonOperator,
5
11
  EnumComparisonOperator,
12
+ Result,
13
+ SearchParamsFullText,
6
14
  Tokenizer,
7
15
  } from "@orama/orama";
8
16
 
@@ -314,32 +322,89 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
314
322
  };
315
323
  };
316
324
 
325
+ /**
326
+ * On a Latin-default index holding pages in non-Latin locales, the tokenizer
327
+ * each of those locales' pages was indexed with, keyed by locale code. A query
328
+ * reads it to tokenize its term the way the pages it searches were.
329
+ */
330
+ const translationTokenizers = new WeakMap<AnyOrama, Map<string, Tokenizer>>();
331
+
332
+ /** A segmenting tokenizer for each document locale Orama's can't serve. */
333
+ const localeTokenizers = (documents: OramaDoc[]): Map<string, Tokenizer> => {
334
+ const tokenizers = new Map<string, Tokenizer>();
335
+ for (const code of new Set(documents.map((doc) => doc.locale ?? ""))) {
336
+ const tokenizer = segmentingTokenizer(code);
337
+ if (tokenizer) {
338
+ tokenizers.set(code, tokenizer);
339
+ }
340
+ }
341
+ return tokenizers;
342
+ };
343
+
344
+ /**
345
+ * One tokenizer that hands each text to its locale's: Orama passes the
346
+ * language given to `insert` and `search` through to `tokenize`, so a page
347
+ * inserted under `ja` and a query searched under `ja` both reach the Japanese
348
+ * tokenizer, and everything else reaches Orama's own, exactly as on an index
349
+ * with no translations.
350
+ */
351
+ const dispatchingTokenizer = (
352
+ tokenizers: Map<string, Tokenizer>
353
+ ): Tokenizer => {
354
+ const standard = components.tokenizer.createTokenizer();
355
+ return {
356
+ language: standard.language,
357
+ normalizationCache: standard.normalizationCache,
358
+ tokenize: (raw, language, prop, withCache) =>
359
+ // Orama's tokenizer rejects a language other than its own, so the
360
+ // locale that picked it is not passed on.
361
+ (tokenizers.get(language ?? "") ?? standard).tokenize(
362
+ raw,
363
+ undefined,
364
+ prop,
365
+ withCache
366
+ ),
367
+ };
368
+ };
369
+
317
370
  /**
318
371
  * Build an in-memory Orama full-text index from search documents. Shared by the
319
372
  * Orama client loader (browser), the MCP server, and assistant grounding (Node),
320
373
  * so ranking is identical wherever docs are queried. `locale` — the site's
321
374
  * `i18n.defaultLocale` — swaps in a word-segmenting tokenizer for every
322
375
  * non-Latin script, all of which Orama's default tokenizer reduces to zero
323
- * tokens; the tokenizer belongs to the database, so on a mixed-locale site it
324
- * applies to every document. That is safe in one direction only: Latin words
376
+ * tokens. A non-Latin default's tokenizer serves every page: Latin words
325
377
  * survive segmentation intact, so English pages on a segmented index stay
326
- * searchable, but non-Latin translations on a Latin-default index still
327
- * collapse to zero tokens.
378
+ * searchable. A Latin default keeps Orama's tokenizer for its Latin-script
379
+ * pages, and each page in a non-Latin locale (the Japanese and Hindi
380
+ * translations of an English site) is indexed with its own locale's tokenizer.
328
381
  */
329
382
  export const buildOramaIndex = async (
330
383
  documents: OramaDoc[],
331
384
  locale?: string
332
385
  ): Promise<AnyOrama> => {
333
- const tokenizer = segmentingTokenizer(locale);
334
- const db = tokenizer
335
- ? create({ components: { tokenizer }, schema: SCHEMA })
336
- : create({ schema: SCHEMA });
337
- await insertMultiple(
338
- db,
339
- documents.map((doc) =>
340
- doc.facets ? { ...doc, facetTerms: toFacetTerms(doc.facets) } : doc
341
- )
386
+ const rows = documents.map((doc) =>
387
+ doc.facets ? { ...doc, facetTerms: toFacetTerms(doc.facets) } : doc
342
388
  );
389
+ const tokenizer = segmentingTokenizer(locale);
390
+ const translations = tokenizer ? undefined : localeTokenizers(documents);
391
+ let db: AnyOrama;
392
+ if (translations && translations.size > 0) {
393
+ db = create({
394
+ components: { tokenizer: dispatchingTokenizer(translations) },
395
+ schema: SCHEMA,
396
+ });
397
+ for (const row of rows) {
398
+ // oxlint-disable-next-line no-await-in-loop -- one at a time, in order, so each page is tokenized under its own locale and ties still rank in document order.
399
+ await insert(db, row, row.locale);
400
+ }
401
+ translationTokenizers.set(db, translations);
402
+ } else {
403
+ db = tokenizer
404
+ ? create({ components: { tokenizer }, schema: SCHEMA })
405
+ : create({ schema: SCHEMA });
406
+ await insertMultiple(db, rows);
407
+ }
343
408
  if (documents.some((doc) => (doc.boost ?? 1) !== 1)) {
344
409
  boostedDatabases.add(db);
345
410
  }
@@ -379,17 +444,56 @@ interface OramaWhereClause {
379
444
  | undefined;
380
445
  }
381
446
 
447
+ /** A ranked match, scored and carrying its document. */
448
+ type Hit = Result<OramaDoc>;
449
+
450
+ /**
451
+ * One search, with the term tokenized under `language`. On a bigrammed
452
+ * tokenizer the strict pass runs first: a term is only meant to match where
453
+ * its bigrams sit together, and scoring them independently lets a page
454
+ * sharing a couple of windows outrank the page the term is about. Terms
455
+ * spanning several words rarely appear in full on one page, so an empty
456
+ * strict result falls back to the default pass rather than reporting no
457
+ * matches.
458
+ */
459
+ const rankedHits = async (
460
+ db: AnyOrama,
461
+ params: SearchParamsFullText<AnyOrama, OramaDoc>,
462
+ language: string | undefined,
463
+ bigrammed: boolean
464
+ ): Promise<Hit[]> => {
465
+ // The result-document generic is OramaDoc because `buildOramaIndex` is the
466
+ // only writer to this database and inserts OramaDoc records (plus the
467
+ // derived `facetTerms`).
468
+ const strict = bigrammed
469
+ ? await search<AnyOrama, OramaDoc>(
470
+ db,
471
+ { ...params, threshold: ALL_TOKENS },
472
+ language
473
+ )
474
+ : undefined;
475
+ const found =
476
+ strict && strict.hits.length > 0
477
+ ? strict
478
+ : await search<AnyOrama, OramaDoc>(db, params, language);
479
+ return found.hits;
480
+ };
481
+
482
+ /** A hit's rank across searches: its relevance times the page's boost. */
483
+ const boostedScore = (hit: Hit): number =>
484
+ hit.score * (hit.document.boost ?? 1);
485
+
382
486
  /**
383
487
  * Query the index, returning the matching documents (highest-ranked first).
384
488
  * `filters` narrows results by exact `where` matches on the enum fields:
385
489
  * `locale` to one language, `contentTypes` to a set of page types, `facets`
386
490
  * to documents carrying every requested `key:value` term.
387
491
  *
388
- * On a bigrammed index the strict pass runs first: a term is only meant to
389
- * match where its bigrams sit together, and scoring them independently lets a
390
- * page sharing a couple of windows outrank the page the term is about. Terms
391
- * spanning several words rarely appear in full on one page, so an empty strict
392
- * result falls back to the default pass rather than reporting no matches.
492
+ * On an index whose translations have tokenizers of their own, a query scoped
493
+ * to a locale is tokenized with that locale's tokenizer. An unscoped query
494
+ * searches each locale's pages with its own tokenizer, and the Latin-script
495
+ * pages with Orama's, then merges the matches by score: every search runs over
496
+ * the one index, so their scores share its term statistics.
393
497
  */
394
498
  export const queryOramaIndex = async (
395
499
  db: AnyOrama,
@@ -424,16 +528,44 @@ export const queryOramaIndex = async (
424
528
  };
425
529
  const params =
426
530
  Object.keys(where).length > 0 ? { ...unfiltered, where } : unfiltered;
427
- const bigrammed = isBigramLanguage(db.tokenizer?.language ?? "");
428
- // The result-document generic is OramaDoc because `buildOramaIndex` is the
429
- // only writer to this database and inserts OramaDoc records (plus the
430
- // derived `facetTerms`).
431
- const strict = bigrammed
432
- ? await search<AnyOrama, OramaDoc>(db, { ...params, threshold: ALL_TOKENS })
433
- : undefined;
434
- const found =
435
- strict && strict.hits.length > 0
436
- ? strict
437
- : await search<AnyOrama, OramaDoc>(db, params);
438
- return found.hits.map((hit) => hit.document);
531
+ const translations = translationTokenizers.get(db);
532
+ if (!translations) {
533
+ const bigrammed = isBigramLanguage(db.tokenizer?.language ?? "");
534
+ const hits = await rankedHits(db, params, undefined, bigrammed);
535
+ return hits.map((hit) => hit.document);
536
+ }
537
+ if (filters?.locale) {
538
+ const { language = "" } = translations.get(filters.locale) ?? {};
539
+ const bigrammed = isBigramLanguage(language);
540
+ const hits = await rankedHits(db, params, filters.locale, bigrammed);
541
+ return hits.map((hit) => hit.document);
542
+ }
543
+ // `nin` matches only pages with a locale, which every Blume-built index
544
+ // gives each page. (Orama's `not` would admit locale-less pages, but it
545
+ // drops the sibling filters beside it.)
546
+ const searches = [
547
+ rankedHits(
548
+ db,
549
+ {
550
+ ...params,
551
+ where: { ...where, locale: { nin: [...translations.keys()] } },
552
+ },
553
+ undefined,
554
+ false
555
+ ),
556
+ ...[...translations].map(([code, tokenizer]) =>
557
+ rankedHits(
558
+ db,
559
+ { ...params, where: { ...where, locale: { eq: code } } },
560
+ code,
561
+ isBigramLanguage(tokenizer.language)
562
+ )
563
+ ),
564
+ ];
565
+ const found = await Promise.all(searches);
566
+ return found
567
+ .flat()
568
+ .toSorted((a, b) => boostedScore(b) - boostedScore(a))
569
+ .slice(0, limit)
570
+ .map((hit) => hit.document);
439
571
  };
@@ -0,0 +1,29 @@
1
+ import { relative } from "pathe";
2
+
3
+ import type { BlumeProject } from "../core/project-graph.ts";
4
+
5
+ /** Where a page is served, and its title: what a search hit links to. */
6
+ export interface SourcePage {
7
+ title: string;
8
+ url: string;
9
+ }
10
+
11
+ /**
12
+ * Each search-indexable page's source file, relative to the project root,
13
+ * paired with the page it renders. The Mixedbread endpoint links a hit to its
14
+ * page through the path `mxbai store sync` recorded for the chunk's file,
15
+ * which is relative to wherever the CLI ran: the project root, or a directory
16
+ * above it in a monorepo. Pages from a source without local files have no
17
+ * path to match and are left out.
18
+ */
19
+ export const sourcePages = (project: BlumeProject): [string, SourcePage][] =>
20
+ project.manifest.routes.flatMap((route): [string, SourcePage][] =>
21
+ route.indexable && route.sourcePath
22
+ ? [
23
+ [
24
+ relative(project.context.root, route.sourcePath),
25
+ { title: route.title, url: route.path },
26
+ ],
27
+ ]
28
+ : []
29
+ );
@@ -23,6 +23,87 @@ const BOOST_RANKING = "desc(boost)";
23
23
  const facetAttribute = (declaration: string): string =>
24
24
  declaration.replace(/^\w+\((?<name>.*)\)$/u, "$<name>");
25
25
 
26
+ /**
27
+ * The largest record Algolia accepts on its Build and Grow plans, in bytes of
28
+ * the record's JSON. One larger record fails the whole upload.
29
+ */
30
+ const MAX_RECORD_BYTES = 10_000;
31
+
32
+ /**
33
+ * A record as uploaded: the search record's fields plus Algolia's `objectID`.
34
+ * (Mapped rather than the interface itself, which the SDK's
35
+ * `Record<string, unknown>` objects don't accept.)
36
+ */
37
+ type AlgoliaObject = Pick<SearchRecord, keyof SearchRecord> & {
38
+ objectID: string;
39
+ };
40
+
41
+ /** A string's or record's size as Algolia measures it: UTF-8 bytes of its JSON. */
42
+ const jsonBytes = (value: string | AlgoliaObject): number =>
43
+ Buffer.byteLength(JSON.stringify(value), "utf-8");
44
+
45
+ /** The JSON of the widest single character: a control character's `\u` escape. */
46
+ const WIDEST_CHARACTER = jsonBytes("\u0000");
47
+
48
+ /** The length of `text`'s longest prefix whose JSON string fits `budget`. */
49
+ const fittingLength = (text: string, budget: number): number => {
50
+ let size = jsonBytes("");
51
+ let end = 0;
52
+ [...text].some((character) => {
53
+ size += jsonBytes(character) - jsonBytes("");
54
+ if (size > budget) {
55
+ return true;
56
+ }
57
+ end += character.length;
58
+ return false;
59
+ });
60
+ return end;
61
+ };
62
+
63
+ /**
64
+ * Cut `text` into pieces whose JSON strings fit `budget` bytes each, at the
65
+ * last space that fits so no word is split; a run with no space in reach (an
66
+ * unspaced script, a long URL) is cut between characters.
67
+ */
68
+ const splitText = (text: string, budget: number): string[] => {
69
+ const pieces: string[] = [];
70
+ let rest = text;
71
+ while (jsonBytes(rest) > budget) {
72
+ const end = fittingLength(rest, budget);
73
+ const space = rest.lastIndexOf(" ", end);
74
+ const cut = space > 0 ? space : end;
75
+ pieces.push(rest.slice(0, cut));
76
+ rest = rest.slice(cut).trimStart();
77
+ }
78
+ pieces.push(rest);
79
+ return pieces;
80
+ };
81
+
82
+ /**
83
+ * A page's record, split to fit Algolia's cap the way Algolia recommends for
84
+ * long documents: every piece carries the page's title, description, URL and
85
+ * facets with a stretch of its body, and the index's `distinct` on `url`
86
+ * shows the page once, at its best-matching piece. The first piece keeps the
87
+ * page's own `objectID`; the rest add `#1`, `#2`… A record whose other fields
88
+ * leave no room for a single character of body can't be split to fit, and
89
+ * uploads whole for Algolia to reject by name.
90
+ */
91
+ const toObjects = (record: SearchRecord): AlgoliaObject[] => {
92
+ const whole = { ...record, objectID: record._id };
93
+ const budget =
94
+ MAX_RECORD_BYTES -
95
+ jsonBytes({ ...whole, content: "", objectID: `${record._id}#000` }) +
96
+ jsonBytes("");
97
+ if (jsonBytes(whole) <= MAX_RECORD_BYTES || budget < WIDEST_CHARACTER) {
98
+ return [whole];
99
+ }
100
+ return splitText(record.content, budget).map((content, index) => ({
101
+ ...record,
102
+ content,
103
+ objectID: index === 0 ? record._id : `${record._id}#${index}`,
104
+ }));
105
+ };
106
+
26
107
  /**
27
108
  * Upload the search records to Algolia. Uses the admin key from
28
109
  * `ALGOLIA_ADMIN_API_KEY` (never the adapter options, which hold only the
@@ -30,9 +111,12 @@ const facetAttribute = (declaration: string): string =>
30
111
  *
31
112
  * Uses `replaceAllObjects`, which atomically replaces the index contents, so
32
113
  * pages deleted or renamed since the last sync don't linger as stale search
33
- * hits that 404 when clicked. Then adds `filterOnly(locale)` and
114
+ * hits that 404 when clicked. A page too long for one record is split across
115
+ * several (see {@link toObjects}). Then adds `filterOnly(locale)` and
34
116
  * `filterOnly(version)` to the index's `attributesForFaceting`, keeping any
35
- * the site declared itself, so the dialog's locale and version filters match.
117
+ * the site declared itself, so the dialog's locale and version filters match,
118
+ * and, once a page is split, `url` as the index's `attributeForDistinct`
119
+ * unless the site set its own.
36
120
  */
37
121
  export const syncAlgolia = async (
38
122
  records: SearchRecord[],
@@ -47,12 +131,13 @@ export const syncAlgolia = async (
47
131
  const { algoliasearch }: typeof AlgoliaSdk = nodeRequire("algoliasearch");
48
132
  const client = algoliasearch(config.appId, adminKey);
49
133
  const { indexName } = config;
50
- await client.replaceAllObjects({
51
- indexName,
52
- objects: records.map((record) => ({ ...record, objectID: record._id })),
53
- });
54
- const { attributesForFaceting = [], customRanking = [] } =
55
- await client.getSettings({ indexName });
134
+ const objects = records.flatMap(toObjects);
135
+ await client.replaceAllObjects({ indexName, objects });
136
+ const {
137
+ attributeForDistinct,
138
+ attributesForFaceting = [],
139
+ customRanking = [],
140
+ } = await client.getSettings({ indexName });
56
141
  const declared = new Set(attributesForFaceting.map(facetAttribute));
57
142
  const missing = FILTER_ATTRIBUTES.filter((name) => !declared.has(name));
58
143
  // Every record carries `boost` (search.boost, 1 by default): ranking by it
@@ -61,7 +146,11 @@ export const syncAlgolia = async (
61
146
  const ranksByBoost = customRanking.some((rule) =>
62
147
  /^(?:asc|desc)\(boost\)$/u.test(rule)
63
148
  );
64
- if (missing.length === 0 && ranksByBoost) {
149
+ // A split page's records share its `url`: distinct on it lists the page
150
+ // once, at its best-matching record.
151
+ const setsDistinct =
152
+ objects.length > records.length && attributeForDistinct === undefined;
153
+ if (missing.length === 0 && ranksByBoost && !setsDistinct) {
65
154
  return;
66
155
  }
67
156
  const { taskID } = await client.setSettings({
@@ -74,6 +163,7 @@ export const syncAlgolia = async (
74
163
  customRanking: ranksByBoost
75
164
  ? customRanking
76
165
  : [...customRanking, BOOST_RANKING],
166
+ ...(setsDistinct && { attributeForDistinct: "url", distinct: true }),
77
167
  },
78
168
  });
79
169
  await client.waitForTask({ indexName, taskID });