blume 1.4.2 → 1.5.0

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 (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -1,5 +1,10 @@
1
1
  import { create, insertMultiple, search } from "@orama/orama";
2
- import type { AnyOrama, Tokenizer } from "@orama/orama";
2
+ import type {
3
+ AnyOrama,
4
+ EnumArrComparisonOperator,
5
+ EnumComparisonOperator,
6
+ Tokenizer,
7
+ } from "@orama/orama";
3
8
 
4
9
  /**
5
10
  * The minimal document shape both the client-side search dialog and the
@@ -13,6 +18,11 @@ export interface OramaDoc {
13
18
  title: string;
14
19
  /** Locale code; indexed as an enum so queries can filter to one language. */
15
20
  locale?: string;
21
+ /**
22
+ * Docs version; indexed as an enum so queries can filter to one version.
23
+ * The current docs carry `""`, which the enum stores and matches exactly.
24
+ */
25
+ version?: string;
16
26
  /** Resolved page `type`; indexed as an enum so queries can filter by type. */
17
27
  contentType?: string;
18
28
  /** Declared facet values (`content.types.<type>.facets`), key → value. */
@@ -35,6 +45,7 @@ const SCHEMA = {
35
45
  locale: "enum",
36
46
  route: "string",
37
47
  title: "string",
48
+ version: "enum",
38
49
  } as const;
39
50
 
40
51
  /** Flatten a facet map to the `key:value` terms the `facetTerms` enum holds. */
@@ -79,6 +90,23 @@ const BIGRAM_LANGUAGES = new Set(["ja", "zh"]);
79
90
  const BIGRAM_SCRIPTS =
80
91
  /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}々〆〇ー゙゚]+$/u;
81
92
 
93
+ /**
94
+ * One index term inside a word-like segment. Being word-like does not make a
95
+ * segment all letters: UAX #29 keeps connector punctuation, format characters
96
+ * and mid-number punctuation *within* a word, so `Intl.Segmenter` reports
97
+ * スネーク_ケース and robots.txt as one segment each. A term is a run of
98
+ * letters, combining marks and digits. Marks are spelling, not punctuation —
99
+ * Thai writes vowels and tones as combining marks, so dropping them leaves
100
+ * consonant skeletons that collapse distinct words (เสื้อ, shirt, and เสือ,
101
+ * tiger, differ by one mark). Two separators stay where the surrounding text
102
+ * makes them part of the word: an apostrophe followed by a letter (don't),
103
+ * and a decimal point or thousands separator flanked by digits (1.0.3,
104
+ * 1,000) — split, either would leave one-letter and one-digit fragments that
105
+ * co-occur on unrelated pages.
106
+ */
107
+ const TERM =
108
+ /[\p{L}\p{M}\p{N}]+(?:(?:['’](?=\p{L})|(?<=\p{N})[.,](?=\p{N}))[\p{L}\p{M}\p{N}]+)*/gu;
109
+
82
110
  /**
83
111
  * Emit every overlapping 2-character window of `run`, or the lone character.
84
112
  * Windows are cut by code point: an ideograph outside the basic plane is a
@@ -109,8 +137,10 @@ const addBigrams = (run: string, tokens: Set<string>): void => {
109
137
  /**
110
138
  * A word-segmenting tokenizer for languages the default splitter can't handle,
111
139
  * built on `Intl.Segmenter` (the same engine `@orama/tokenizers` wraps).
112
- * Input is lowercased before segmenting — unlike the upstream tokenizers —
113
- * so Latin terms ("GDPR", English pages on a mixed-locale site) still match
140
+ * Input is NFC-normalized and lowercased before segmenting — unlike the
141
+ * upstream tokenizers so decomposed text (macOS filenames, some CMS
142
+ * pipelines) indexes the same terms a composed query produces, and Latin
143
+ * terms ("GDPR", English pages on a mixed-locale site) still match
114
144
  * case-insensitively. Returns `undefined` for languages the default tokenizer
115
145
  * already serves, and on runtimes without `Intl.Segmenter`, where the caller
116
146
  * falls back to Orama's default.
@@ -118,16 +148,24 @@ const addBigrams = (run: string, tokens: Set<string>): void => {
118
148
  * On a {@link BIGRAM_LANGUAGES} index, runs of adjacent
119
149
  * {@link BIGRAM_SCRIPTS} segments are joined and re-cut into character
120
150
  * bigrams; everything else (Latin, digits, and every segment on a Korean or
121
- * Thai index) is emitted as the segmenter produced it. Punctuation and spaces
122
- * are not word-like, so they end a run 「クーリング・オフ」 bigrams either
123
- * side of the interpunct rather than across it.
151
+ * Thai index) is emitted one {@link TERM} at a time. Separators end a run
152
+ * either way whether they stand between segments, as 「クーリング・オフ」
153
+ * does, or inside one.
154
+ */
155
+ /**
156
+ * `Intl.Segmenter` is missing on some runtimes even though the lib type
157
+ * declares it, so the constructor's presence is probed before use.
124
158
  */
159
+ const hasSegmenter = (
160
+ segmenter: typeof Intl.Segmenter | undefined
161
+ ): segmenter is typeof Intl.Segmenter => typeof segmenter === "function";
162
+
125
163
  const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
126
164
  const language = locale?.toLowerCase().split(/[-_]/u)[0] ?? "";
127
165
  if (!SEGMENTED_LANGUAGES.has(language)) {
128
166
  return;
129
167
  }
130
- if (typeof Intl.Segmenter !== "function") {
168
+ if (!hasSegmenter(Intl.Segmenter)) {
131
169
  return;
132
170
  }
133
171
  const segmenter = new Intl.Segmenter(language, { granularity: "word" });
@@ -146,17 +184,35 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
146
184
  run = "";
147
185
  }
148
186
  };
149
- for (const segment of segmenter.segment(raw.toLowerCase())) {
187
+ const take = (term: string): void => {
188
+ if (bigram && BIGRAM_SCRIPTS.test(term)) {
189
+ run += term;
190
+ return;
191
+ }
192
+ flush();
193
+ tokens.add(term);
194
+ };
195
+ for (const segment of segmenter.segment(
196
+ raw.normalize("NFC").toLowerCase()
197
+ )) {
150
198
  if (!segment.isWordLike) {
151
199
  flush();
152
200
  continue;
153
201
  }
154
- if (bigram && BIGRAM_SCRIPTS.test(segment.segment)) {
155
- run += segment.segment;
156
- continue;
202
+ let end = 0;
203
+ for (const match of segment.segment.matchAll(TERM)) {
204
+ // A gap means punctuation stood there, which ends the run as surely
205
+ // as a non-word-like segment would: スネーク_ケース pairs either side
206
+ // of the connector, never across it.
207
+ if (match.index > end) {
208
+ flush();
209
+ }
210
+ take(match[0]);
211
+ end = match.index + match[0].length;
212
+ }
213
+ if (end < segment.segment.length) {
214
+ flush();
157
215
  }
158
- flush();
159
- tokens.add(segment.segment);
160
216
  }
161
217
  flush();
162
218
  return [...tokens];
@@ -171,17 +227,18 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
171
227
  * `i18n.defaultLocale` — swaps in a word-segmenting tokenizer for languages
172
228
  * written without spaces (Japanese, Chinese, Korean, Thai); the tokenizer
173
229
  * belongs to the database, so on a mixed-locale site it applies to every
174
- * document, which is safe because Latin words survive segmentation intact.
230
+ * document, which is safe because words in other scripts — Latin, and
231
+ * mark-bearing scripts like Hebrew or Devanagari — survive segmentation
232
+ * intact.
175
233
  */
176
234
  export const buildOramaIndex = async (
177
235
  documents: OramaDoc[],
178
236
  locale?: string
179
237
  ): Promise<AnyOrama> => {
180
238
  const tokenizer = segmentingTokenizer(locale);
181
- const db = create({
182
- schema: SCHEMA,
183
- ...(tokenizer ? { components: { tokenizer } } : {}),
184
- });
239
+ const db = tokenizer
240
+ ? create({ components: { tokenizer }, schema: SCHEMA })
241
+ : create({ schema: SCHEMA });
185
242
  await insertMultiple(
186
243
  db,
187
244
  documents.map((doc) =>
@@ -205,6 +262,23 @@ export interface OramaQueryFilters {
205
262
  facets?: Record<string, string>;
206
263
  /** Keep only documents in this locale. */
207
264
  locale?: string;
265
+ /**
266
+ * Keep only documents of this docs version (`""` is the current docs — a
267
+ * meaningful filter value, so absence alone disables version filtering).
268
+ */
269
+ version?: string;
270
+ }
271
+
272
+ /**
273
+ * The exact-match `where` clause the filters compile to. Orama types `where`
274
+ * openly (any schema property to an operator), mirrored here by the index
275
+ * signature; this module only ever emits the two enum operators.
276
+ */
277
+ interface OramaWhereClause {
278
+ [property: string]:
279
+ | EnumArrComparisonOperator
280
+ | EnumComparisonOperator
281
+ | undefined;
208
282
  }
209
283
 
210
284
  /**
@@ -226,27 +300,38 @@ export const queryOramaIndex = async (
226
300
  filters?: OramaQueryFilters
227
301
  ): Promise<OramaDoc[]> => {
228
302
  const facetTerms = filters?.facets ? toFacetTerms(filters.facets) : [];
229
- const where = {
230
- ...(filters?.locale ? { locale: { eq: filters.locale } } : {}),
231
- ...(filters?.contentTypes && filters.contentTypes.length > 0
232
- ? { contentType: { in: filters.contentTypes } }
233
- : {}),
234
- ...(facetTerms.length > 0
235
- ? { facetTerms: { containsAll: facetTerms } }
236
- : {}),
237
- };
238
- const params = {
303
+ const where: OramaWhereClause = {};
304
+ if (filters?.locale) {
305
+ where.locale = { eq: filters.locale };
306
+ }
307
+ // `""` (the current docs) is a real filter value, so test for presence.
308
+ if (filters?.version !== undefined) {
309
+ where.version = { eq: filters.version };
310
+ }
311
+ if (filters?.contentTypes && filters.contentTypes.length > 0) {
312
+ where.contentType = { in: filters.contentTypes };
313
+ }
314
+ if (facetTerms.length > 0) {
315
+ where.facetTerms = { containsAll: facetTerms };
316
+ }
317
+ const unfiltered = {
239
318
  boost: BOOST,
240
319
  limit,
241
320
  properties: ["title", "description", "content"],
242
321
  term,
243
- ...(Object.keys(where).length > 0 ? { where } : {}),
244
322
  };
323
+ const params =
324
+ Object.keys(where).length > 0 ? { ...unfiltered, where } : unfiltered;
245
325
  const bigrammed = BIGRAM_LANGUAGES.has(db.tokenizer?.language ?? "");
326
+ // The result-document generic is OramaDoc because `buildOramaIndex` is the
327
+ // only writer to this database and inserts OramaDoc records (plus the
328
+ // derived `facetTerms`).
246
329
  const strict = bigrammed
247
- ? await search(db, { ...params, threshold: ALL_TOKENS })
330
+ ? await search<AnyOrama, OramaDoc>(db, { ...params, threshold: ALL_TOKENS })
248
331
  : undefined;
249
332
  const found =
250
- strict && strict.hits.length > 0 ? strict : await search(db, params);
251
- return found.hits.map((hit) => hit.document as unknown as OramaDoc);
333
+ strict && strict.hits.length > 0
334
+ ? strict
335
+ : await search<AnyOrama, OramaDoc>(db, params);
336
+ return found.hits.map((hit) => hit.document);
252
337
  };
@@ -26,8 +26,13 @@ export const resolveSearchPopular = (
26
26
  popular: { href: string; icon?: string; label: string }[],
27
27
  basePath: string
28
28
  ): SearchPopularPage[] =>
29
- popular.map(({ href, icon, label }) => ({
30
- ...(icon ? { icon } : {}),
31
- label,
32
- route: withBasePath(basePath, href),
33
- }));
29
+ popular.map(({ href, icon, label }) => {
30
+ const page: SearchPopularPage = {
31
+ label,
32
+ route: withBasePath(basePath, href),
33
+ };
34
+ if (icon) {
35
+ page.icon = icon;
36
+ }
37
+ return page;
38
+ });
@@ -29,7 +29,7 @@ export interface SearchProviderMeta {
29
29
  syncs: boolean;
30
30
  }
31
31
 
32
- export const SEARCH_PROVIDERS: Record<SearchProvider, SearchProviderMeta> = {
32
+ export const SEARCH_PROVIDERS = {
33
33
  algolia: {
34
34
  kind: "hosted",
35
35
  requiresServer: false,
@@ -80,7 +80,7 @@ export const SEARCH_PROVIDERS: Record<SearchProvider, SearchProviderMeta> = {
80
80
  runtimeDeps: ["typesense"],
81
81
  syncs: true,
82
82
  },
83
- };
83
+ } satisfies Record<SearchProvider, SearchProviderMeta>;
84
84
 
85
85
  export const searchProviderMeta = (
86
86
  provider: SearchProvider
@@ -45,6 +45,8 @@ export const syncSearchProvider = async (
45
45
  `Synced ${records.length} record(s) to ${search.provider}`
46
46
  );
47
47
  } catch (error) {
48
+ // SAFETY: the three sync clients surface network/auth failures as Error
49
+ // instances; the message is read only to annotate the skip warning.
48
50
  reporter.warn(`Search sync skipped: ${(error as Error).message}`);
49
51
  }
50
52
  };
@@ -56,9 +56,10 @@ export const syncTypesense = async (
56
56
  { name: "content", type: "string" },
57
57
  { name: "url", type: "string" },
58
58
  { facet: true, name: "tag", optional: true, type: "string" },
59
- // Carried as a facet so an i18n site can filter hosted results per
60
- // language (the SearchRecord contract).
59
+ // Carried as facets so hosted results can filter per language and per
60
+ // docs version (the SearchRecord contract; current docs = "current").
61
61
  { facet: true, name: "locale", optional: true, type: "string" },
62
+ { facet: true, name: "version", optional: true, type: "string" },
62
63
  ],
63
64
  name: config.collection,
64
65
  });
@@ -71,6 +72,7 @@ export const syncTypesense = async (
71
72
  tag: record.tag,
72
73
  title: record.title,
73
74
  url: record.url,
75
+ version: record.version,
74
76
  }));
75
77
  await client
76
78
  .collections(config.collection)
package/src/seo/jsonld.ts CHANGED
@@ -27,10 +27,25 @@ export interface StructuredDataInput {
27
27
  }
28
28
 
29
29
  /** schema.org `@type` for each content type; defaults to TechArticle. */
30
- const ARTICLE_TYPES: Record<string, string> = {
30
+ const ARTICLE_TYPES = {
31
31
  blog: "BlogPosting",
32
32
  changelog: "TechArticle",
33
- };
33
+ } as const;
34
+
35
+ /**
36
+ * `hasOwn` (not a bare index) so a content type named like an
37
+ * `Object.prototype` member can't resolve a function up the prototype chain.
38
+ */
39
+ const isArticleType = (value: string): value is keyof typeof ARTICLE_TYPES =>
40
+ Object.hasOwn(ARTICLE_TYPES, value);
41
+
42
+ /** A value a schema.org node property can hold. */
43
+ type JsonLdValue = string | number | JsonLdValue[] | JsonLdNode;
44
+
45
+ /** A schema.org node: JSON-LD keys to concrete JSON values. */
46
+ export interface JsonLdNode {
47
+ [key: string]: JsonLdValue;
48
+ }
34
49
 
35
50
  const trimSlash = (value: string): string => value.replace(/\/$/u, "");
36
51
 
@@ -59,14 +74,14 @@ export const toIso = (value: DateInput | undefined): string | undefined => {
59
74
  */
60
75
  export const buildStructuredData = (
61
76
  input: StructuredDataInput
62
- ): Record<string, unknown> | null => {
77
+ ): JsonLdNode | null => {
63
78
  const base = input.siteUrl ? trimSlash(input.siteUrl) : null;
64
79
  // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top
65
80
  // so JSON-LD URLs match the served location.
66
81
  const deployBase = normalizeBasePath(input.base);
67
82
  const pageUrl = absolute(base, withBasePath(deployBase, input.route));
68
83
  const rootUrl = absolute(base, deployBase);
69
- const graph: Record<string, unknown>[] = [];
84
+ const graph: JsonLdNode[] = [];
70
85
 
71
86
  if (base) {
72
87
  graph.push({
@@ -80,9 +95,12 @@ export const buildStructuredData = (
80
95
  // The homepage is fully described by the WebSite node; deeper pages get an
81
96
  // article node plus a breadcrumb trail.
82
97
  if (input.route !== "/") {
83
- const node: Record<string, unknown> = {
98
+ const pageType = input.pageType ?? "";
99
+ const node: JsonLdNode = {
84
100
  "@id": `${pageUrl}#page`,
85
- "@type": ARTICLE_TYPES[input.pageType ?? ""] ?? "TechArticle",
101
+ "@type": isArticleType(pageType)
102
+ ? ARTICLE_TYPES[pageType]
103
+ : "TechArticle",
86
104
  headline: input.title,
87
105
  inLanguage: input.locale || "en",
88
106
  name: input.title,
@@ -1,3 +1,7 @@
1
+ /** Frontmatter defense in depth: only a real string can carry a handle. */
2
+ const isString = <Value>(value: Value): value is Value & string =>
3
+ typeof value === "string";
4
+
1
5
  /**
2
6
  * Normalize an X account to the leading `@` that `twitter:site`/`twitter:creator`
3
7
  * require, so `acme`, `@acme`, and ` @acme ` all land on `@acme`. Empty or
@@ -7,10 +11,11 @@
7
11
  * Astro's collections carry no schema here, so a page's `seo.x.creator` reaches
8
12
  * them as raw frontmatter, and the schema's own transform never runs on it.
9
13
  * (Blume's page pipeline does reject a non-string `creator` before the page is
10
- * built, so `unknown` is defense in depth rather than the expected path.)
14
+ * built, so the string guard is defense in depth rather than the expected
15
+ * path.)
11
16
  */
12
- export const normalizeXHandle = (value: unknown): string | undefined => {
13
- if (typeof value !== "string") {
17
+ export const normalizeXHandle = <Value>(value: Value): string | undefined => {
18
+ if (!isString(value)) {
14
19
  return;
15
20
  }
16
21
  const handle = value.trim().replace(/^@+/u, "");
@@ -12,11 +12,16 @@
12
12
  * Values are Lucide inner-SVG markup; the client `svg()` helpers wrap them in an
13
13
  * `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" …>`.
14
14
  */
15
- export const chromeIcons: Record<string, string> = {
15
+ /** Inner-SVG markup keyed by the icon name a client script requests. */
16
+ interface ChromeIconSet {
17
+ [name: string]: string;
18
+ }
19
+
20
+ export const chromeIcons: ChromeIconSet = {
16
21
  check: '<path d="M20 6 9 17l-5-5"/>',
17
22
  copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
18
23
  file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
19
24
  search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
20
25
  sparkles:
21
- '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/><path d="M5 3v4"/><path d="M3 5h4"/><path d="M19 17v4"/><path d="M17 19h4"/>',
26
+ '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1-1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/><path d="M5 3v4"/><path d="M3 5h4"/><path d="M19 17v4"/><path d="M17 19h4"/>',
22
27
  };
@@ -82,11 +82,11 @@ export type FontEntry =
82
82
  variants: LocalFontVariant[];
83
83
  };
84
84
 
85
- const FALLBACKS: Record<FontCategory, string[]> = {
85
+ const FALLBACKS = {
86
86
  mono: ["ui-monospace", "SF Mono", "Menlo", "monospace"],
87
87
  sans: ["ui-sans-serif", "system-ui", "sans-serif"],
88
88
  serif: ["ui-serif", "Georgia", "serif"],
89
- };
89
+ } satisfies Record<FontCategory, string[]>;
90
90
 
91
91
  /** Slug -> Google family + weights + fallback category. Keep keys alphabetical. */
92
92
  export const GOOGLE_FONTS = {
@@ -223,12 +223,16 @@ const SLOTS: FontSlot[] = ["display", "body", "mono"];
223
223
  const slotCategory = (slot: FontSlot): FontCategory =>
224
224
  slot === "mono" ? "mono" : "sans";
225
225
 
226
+ /** Whether a slot value is the slug-string form (vs a custom font object). */
227
+ const isSlugValue = (value: FontValue): value is string =>
228
+ typeof value === "string";
229
+
226
230
  /** A slot value normalized into an entry, or null for an unknown slug string. */
227
231
  const resolveFontValue = (
228
232
  slot: FontSlot,
229
233
  value: FontValue
230
234
  ): FontEntry | null => {
231
- if (typeof value === "string") {
235
+ if (isSlugValue(value)) {
232
236
  if (!isFontSlug(value)) {
233
237
  return null;
234
238
  }
@@ -320,7 +324,7 @@ export const buildFontEntries = (fonts: FontsConfig): FontEntry[] => {
320
324
 
321
325
  /** The slug backing a slot's CSS variable, or null for an unknown slug string. */
322
326
  const slotSlug = (value: FontValue): string | null => {
323
- if (typeof value === "string") {
327
+ if (isSlugValue(value)) {
324
328
  return isFontSlug(value) ? value : null;
325
329
  }
326
330
  return slugifyFontName(value.name);
@@ -17,9 +17,11 @@ import { getIconData, iconToSVG } from "@iconify/utils";
17
17
  // strips it when externalizing) and then rejects the module, whereas `require`
18
18
  // of a JSON file needs no attribute and works under both Node and Bun.
19
19
  const requireJson = createRequire(import.meta.url);
20
+ // SAFETY: the required file is the Iconify-published icon-set JSON, whose
21
+ // shape is exactly `IconifyJSON`.
20
22
  const loadSet = (pkg: string): IconifyJSON => requireJson(pkg) as IconifyJSON;
21
23
 
22
- const SETS: Record<string, IconifyJSON> = {
24
+ const SETS = {
23
25
  lucide: loadSet("@iconify-json/lucide/icons.json"),
24
26
  };
25
27
 
@@ -27,7 +29,7 @@ const SETS: Record<string, IconifyJSON> = {
27
29
  const DEFAULT_SET = "lucide";
28
30
 
29
31
  /** Explicit `prefix:name` prefixes. Lucide is the only bundled set. */
30
- const PREFIX_SETS: Record<string, string> = {
32
+ const PREFIX_SETS = {
31
33
  lucide: "lucide",
32
34
  };
33
35
 
@@ -2,8 +2,12 @@ import type { ResolvedConfig } from "../core/schema.ts";
2
2
 
3
3
  const FALLBACK_ACCENT = "oklch(0.62 0.16 250)";
4
4
 
5
- /** Named accent presets mapped to OKLCH values. */
6
- const ACCENTS: Record<string, string> = {
5
+ /**
6
+ * Named accent presets mapped to OKLCH values. The single source of truth for
7
+ * preset colors: the theme CSS and the OG card (og/card.ts) both resolve from
8
+ * this table, so a site and its social cards can't disagree about "blue".
9
+ */
10
+ export const ACCENTS = {
7
11
  blue: FALLBACK_ACCENT,
8
12
  green: "oklch(0.6 0.16 150)",
9
13
  orange: "oklch(0.68 0.17 50)",
@@ -11,7 +15,16 @@ const ACCENTS: Record<string, string> = {
11
15
  purple: "oklch(0.58 0.2 290)",
12
16
  red: "oklch(0.58 0.22 25)",
13
17
  teal: "oklch(0.6 0.12 195)",
14
- };
18
+ } satisfies Record<string, string>;
19
+
20
+ /**
21
+ * Whether a raw config value names an accent preset. `hasOwn` keeps a value
22
+ * like "constructor" from resolving an Object.prototype member — which would
23
+ * stringify a function into the generated CSS, breaking the rule (the exact
24
+ * breakout {@link safeColor} exists to prevent).
25
+ */
26
+ export const isAccentPreset = (value: string): value is keyof typeof ACCENTS =>
27
+ Object.hasOwn(ACCENTS, value);
15
28
 
16
29
  // Characters valid in a CSS color value (hex, rgb/hsl/oklch functions, named
17
30
  // colors). Anything else — notably `;`, `{`, `}` — could break out of the
@@ -22,27 +35,20 @@ const CSS_COLOR = /^[\w\s#%.,()/+-]+$/u;
22
35
  const safeColor = (value: string, fallback: string): string =>
23
36
  CSS_COLOR.test(value.trim()) ? value.trim() : fallback;
24
37
 
25
- /**
26
- * Resolve a named preset or fall back to {@link safeColor}. `hasOwn` keeps a
27
- * value like "constructor" from resolving an Object.prototype member — which
28
- * would stringify a function into the generated CSS, breaking the rule (the
29
- * exact breakout safeColor exists to prevent).
30
- */
38
+ /** Resolve a named preset or fall back to {@link safeColor}. */
31
39
  const presetOrColor = (value: string): string =>
32
- Object.hasOwn(ACCENTS, value)
33
- ? (ACCENTS[value] as string)
34
- : safeColor(value, FALLBACK_ACCENT);
40
+ isAccentPreset(value) ? ACCENTS[value] : safeColor(value, FALLBACK_ACCENT);
35
41
 
36
42
  /** Like {@link safeColor} but drops an unsafe/absent value to `null`. */
37
43
  const safeColorOrNull = (value: string | undefined): string | null =>
38
44
  value && CSS_COLOR.test(value.trim()) ? value.trim() : null;
39
45
 
40
- const RADII: Record<ResolvedConfig["theme"]["radius"], string> = {
46
+ const RADII = {
41
47
  lg: "0.75rem",
42
48
  md: "0.5rem",
43
49
  none: "0",
44
50
  sm: "0.25rem",
45
- };
51
+ } satisfies Record<ResolvedConfig["theme"]["radius"], string>;
46
52
 
47
53
  const cssString = (value: string): string => JSON.stringify(value);
48
54
 
@@ -112,6 +118,12 @@ ${tokens.join("\n")}
112
118
  `;
113
119
  };
114
120
 
121
+ /** Per-mode accent CSS colors. */
122
+ export interface AccentColors {
123
+ dark: string;
124
+ light: string;
125
+ }
126
+
115
127
  /**
116
128
  * Resolve the configured accent to per-mode CSS colors. A named accent
117
129
  * resolves to its preset; any other value is treated as a raw CSS color so
@@ -121,7 +133,7 @@ ${tokens.join("\n")}
121
133
  */
122
134
  export const resolveAccent = (
123
135
  theme: ResolvedConfig["theme"]
124
- ): { dark: string; light: string } => ({
136
+ ): AccentColors => ({
125
137
  dark: presetOrColor(theme.accent.dark),
126
138
  light: presetOrColor(theme.accent.light),
127
139
  });
@@ -36,8 +36,10 @@ export const emptyLedger = (): TranslationLedger => ({
36
36
 
37
37
  /**
38
38
  * Hash the raw source text (frontmatter included), so any edit invalidates
39
- * every locale's stamp. sha256-16 like the audit snapshot's content hash
40
- * never `hashText` (djb2), which is an ephemeral-cache-only hash.
39
+ * every locale's stamp. sha256-16 like the audit snapshot's content hash.
40
+ * Kept as its own function rather than sharing core's `hashText`: ledger
41
+ * stamps persist in user repos, so this hash must never change shape when an
42
+ * ephemeral cache hash does.
41
43
  */
42
44
  export const hashSource = (text: string): string =>
43
45
  createHash("sha256").update(text).digest("hex").slice(0, 16);
@@ -39,6 +39,15 @@ export interface TranslatableMeta {
39
39
 
40
40
  const META_FILES = ["**/meta.ts", "**/meta.js", "**/meta.mjs"];
41
41
 
42
+ /**
43
+ * Whether a loaded meta module default-exports a factory function. Generic so
44
+ * it can decode the loader's untyped module value at this boundary.
45
+ */
46
+ const isFactoryModule = <T>(
47
+ value: T
48
+ ): value is T & ((...args: never[]) => FolderMeta) =>
49
+ typeof value === "function";
50
+
42
51
  /** Where a locale's generated meta module lives (always written as `meta.ts`). */
43
52
  export const metaTargetPath = (
44
53
  meta: TranslatableMeta,
@@ -92,7 +101,7 @@ export const discoverTranslatableMeta = async (
92
101
  readFile(file, "utf-8"),
93
102
  load(file),
94
103
  ]);
95
- if (typeof mod === "function") {
104
+ if (isFactoryModule(mod)) {
96
105
  diagnostics.push({
97
106
  code: "BLUME_TRANSLATE_META_FACTORY",
98
107
  file,
@@ -130,14 +139,14 @@ export const generateMetaModule = (
130
139
  meta: FolderMeta,
131
140
  translatedTitle: string
132
141
  ): string => {
133
- const data: Record<string, unknown> = {
142
+ const data = {
134
143
  ...meta,
135
144
  title: translatedTitle,
136
145
  };
137
- const lines = Object.keys(data)
138
- .toSorted()
139
- .filter((key) => data[key] !== undefined)
140
- .map((key) => ` ${key}: ${JSON.stringify(data[key])},`);
146
+ const lines = Object.entries(data)
147
+ .toSorted(([a], [b]) => (a < b ? -1 : 1))
148
+ .filter(([, value]) => value !== undefined)
149
+ .map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`);
141
150
  return [
142
151
  "// Generated by `blume translate` — edit the default locale's meta file",
143
152
  "// and rerun the translation instead of editing this copy.",