blume 1.3.1 → 1.4.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.
- package/CHANGELOG.md +50 -0
- package/dist/cli/index.js +3221 -201
- package/dist/cli/index.js.map +73 -61
- package/dist/types/core/base-path.d.ts +5 -0
- package/dist/types/core/config-input.d.ts +82 -6
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +19 -2
- package/dist/types/core/sources/types.d.ts +5 -0
- package/dist/types/core/types.d.ts +4 -3
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/ai.mdx +15 -1
- package/docs/configuration/index.mdx +26 -0
- package/docs/configuration/search.mdx +1 -3
- package/docs/content/i18n.mdx +13 -1
- package/docs/content/navigation.mdx +11 -0
- package/docs/reference/cli.mdx +4 -0
- package/docs/reference/frontmatter.mdx +33 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +80 -0
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/mcp/data.ts +10 -4
- package/src/ai/mcp/server.ts +74 -3
- package/src/ai/mcp/tools.ts +2 -2
- package/src/astro/integration.ts +3 -1
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +66 -18
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +129 -24
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/index.ts +2 -0
- package/src/components/Icon.astro +2 -7
- package/src/components/content/Step.astro +3 -8
- package/src/components/content/Tab.astro +20 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +4 -4
- package/src/components/layout/PageActions.astro +12 -7
- package/src/components/layout/Search.astro +15 -20
- package/src/components/layout/search/orama.ts +3 -1
- package/src/core/base-path.ts +9 -0
- package/src/core/config-input.ts +84 -6
- package/src/core/graph.ts +46 -2
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/i18n.ts +31 -0
- package/src/core/nav-diagnostics.ts +13 -34
- package/src/core/project-graph.ts +13 -2
- package/src/core/schema.ts +174 -74
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/types.ts +5 -0
- package/src/core/types.ts +4 -3
- package/src/core/ui-packs/ar.ts +42 -1
- package/src/core/ui-packs/bg.ts +42 -1
- package/src/core/ui-packs/bn.ts +42 -1
- package/src/core/ui-packs/ca.ts +44 -1
- package/src/core/ui-packs/cs.ts +42 -1
- package/src/core/ui-packs/da.ts +42 -1
- package/src/core/ui-packs/de.ts +42 -1
- package/src/core/ui-packs/el.ts +44 -1
- package/src/core/ui-packs/es.ts +44 -1
- package/src/core/ui-packs/fa.ts +42 -1
- package/src/core/ui-packs/fi.ts +42 -1
- package/src/core/ui-packs/fr.ts +44 -1
- package/src/core/ui-packs/he.ts +42 -1
- package/src/core/ui-packs/hi.ts +42 -1
- package/src/core/ui-packs/hr.ts +42 -1
- package/src/core/ui-packs/hu.ts +42 -1
- package/src/core/ui-packs/id.ts +42 -1
- package/src/core/ui-packs/it.ts +44 -1
- package/src/core/ui-packs/ja.ts +44 -1
- package/src/core/ui-packs/ko.ts +44 -1
- package/src/core/ui-packs/nl.ts +42 -1
- package/src/core/ui-packs/no.ts +42 -1
- package/src/core/ui-packs/pl.ts +42 -1
- package/src/core/ui-packs/pt-br.ts +44 -1
- package/src/core/ui-packs/pt.ts +44 -1
- package/src/core/ui-packs/ro.ts +42 -1
- package/src/core/ui-packs/ru.ts +42 -1
- package/src/core/ui-packs/sk.ts +42 -1
- package/src/core/ui-packs/sr.ts +42 -1
- package/src/core/ui-packs/sv.ts +42 -1
- package/src/core/ui-packs/th.ts +44 -1
- package/src/core/ui-packs/tr.ts +42 -1
- package/src/core/ui-packs/uk.ts +42 -1
- package/src/core/ui-packs/vi.ts +44 -1
- package/src/core/ui-packs/zh-tw.ts +44 -1
- package/src/core/ui-packs/zh.ts +44 -1
- package/src/deploy/adapter-output.ts +44 -5
- package/src/deploy/cloudflare-negotiation.ts +527 -0
- package/src/deploy/redirects.ts +13 -0
- package/src/eval/agents.ts +1 -1
- package/src/search/documents.ts +11 -0
- package/src/search/facets.ts +33 -0
- package/src/search/orama-index.ts +48 -6
- package/src/search/popular-icon.ts +33 -0
- package/src/theme/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +148 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +360 -0
- package/src/translate/run.ts +376 -0
- package/src/translate/validate.ts +171 -0
- package/src/translate/work-list.ts +0 -0
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* bare `/` normalizes to `""`, so an unset/`"/"` base is a clean no-op.
|
|
18
18
|
*/
|
|
19
19
|
export declare const normalizeBasePath: (input?: string) => string;
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a served path for comparison: drop the trailing slash (Astro serves
|
|
22
|
+
* `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
|
|
23
|
+
*/
|
|
24
|
+
export declare const normalizePath: (path: string) => string;
|
|
20
25
|
/**
|
|
21
26
|
* Whether a link target is a root-relative internal path (`/x`) — the only
|
|
22
27
|
* shape a base path applies to. Protocol-relative (`//host`), absolute URLs,
|
|
@@ -226,15 +226,81 @@ export interface ContentConfig {
|
|
|
226
226
|
* Releases, Sanity, Notion, or a custom `ContentSource`.
|
|
227
227
|
*/
|
|
228
228
|
sources?: ContentSourceInput[];
|
|
229
|
+
/**
|
|
230
|
+
* Per-type content definitions, keyed by the frontmatter `type` they apply
|
|
231
|
+
* to (including `defaultType`, for pages that set none):
|
|
232
|
+
*
|
|
233
|
+
* ```ts
|
|
234
|
+
* import { z } from "zod";
|
|
235
|
+
*
|
|
236
|
+
* content: {
|
|
237
|
+
* types: {
|
|
238
|
+
* rfc: {
|
|
239
|
+
* frontmatter: {
|
|
240
|
+
* domain: z.string(),
|
|
241
|
+
* status: z.enum(["draft", "enforced"]),
|
|
242
|
+
* },
|
|
243
|
+
* },
|
|
244
|
+
* },
|
|
245
|
+
* },
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
types?: Record<string, ContentTypeConfig>;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A per-type content definition: configuration that applies only to pages
|
|
252
|
+
* whose resolved frontmatter `type` matches the map key.
|
|
253
|
+
*/
|
|
254
|
+
export interface ContentTypeConfig {
|
|
255
|
+
/**
|
|
256
|
+
* Custom frontmatter keys whose values become filterable facets for pages
|
|
257
|
+
* of this type. Faceted values ride along on search documents
|
|
258
|
+
* (`blume-search.json` and the MCP index), and the MCP `search_docs` and
|
|
259
|
+
* `list_pages` tools accept a `filters` input matching against them:
|
|
260
|
+
*
|
|
261
|
+
* ```ts
|
|
262
|
+
* content: {
|
|
263
|
+
* types: {
|
|
264
|
+
* rfc: {
|
|
265
|
+
* facets: ["domain", "status"],
|
|
266
|
+
* frontmatter: { domain: z.string(), status: z.string() },
|
|
267
|
+
* },
|
|
268
|
+
* },
|
|
269
|
+
* },
|
|
270
|
+
* ```
|
|
271
|
+
*
|
|
272
|
+
* Each name must be a custom key declared for the type — in its
|
|
273
|
+
* `frontmatter` map or the site-wide `frontmatter.extend`. String values
|
|
274
|
+
* facet as-is; numbers and booleans are stringified; anything else
|
|
275
|
+
* (objects, arrays, transformed dates) does not facet.
|
|
276
|
+
*/
|
|
277
|
+
facets?: string[];
|
|
278
|
+
/**
|
|
279
|
+
* Custom frontmatter keys for pages of this type, layered on top of the
|
|
280
|
+
* site-wide `frontmatter.extend` (a key can be declared in one or the
|
|
281
|
+
* other, not both). Schemas follow the same rules as `extend`: any
|
|
282
|
+
* Standard Schema library works, every declared key is validated on every
|
|
283
|
+
* page of the type — absent ones included — so a required schema enforces
|
|
284
|
+
* the key type-wide (mark it `.optional()` to validate only when present),
|
|
285
|
+
* and validated values land on the page record's `custom` field. Built-in
|
|
286
|
+
* frontmatter fields cannot be redeclared.
|
|
287
|
+
*/
|
|
288
|
+
frontmatter?: Record<string, StandardSchema>;
|
|
229
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* A header label, optionally per locale: a plain string, or a map of locale
|
|
292
|
+
* code to label (`{ en: "Docs", ja: "ドキュメント" }`). The active locale's
|
|
293
|
+
* entry wins, then the default locale's, then the map's first entry.
|
|
294
|
+
*/
|
|
295
|
+
export type LocalizableLabel = string | Record<string, string>;
|
|
230
296
|
/** A single item inside a header tab's dropdown. */
|
|
231
297
|
export interface NavTabItem {
|
|
232
298
|
/** Secondary line under the label. */
|
|
233
299
|
description?: string;
|
|
234
300
|
/** Lucide icon name shown beside the label. */
|
|
235
301
|
icon?: string;
|
|
236
|
-
/** Item label. */
|
|
237
|
-
label:
|
|
302
|
+
/** Item label, optionally per locale. */
|
|
303
|
+
label: LocalizableLabel;
|
|
238
304
|
/** Route the item links to. */
|
|
239
305
|
path: string;
|
|
240
306
|
/** Short tag/pill (e.g. `New`, `Beta`). */
|
|
@@ -255,8 +321,8 @@ export interface NavTab {
|
|
|
255
321
|
icon?: string;
|
|
256
322
|
/** Dropdown items; omit for a plain link tab. */
|
|
257
323
|
items?: NavTabItem[];
|
|
258
|
-
/** Tab label. */
|
|
259
|
-
label:
|
|
324
|
+
/** Tab label, optionally per locale. */
|
|
325
|
+
label: LocalizableLabel;
|
|
260
326
|
/** Route the tab links to. */
|
|
261
327
|
path: string;
|
|
262
328
|
}
|
|
@@ -434,7 +500,10 @@ export interface MixedbreadSearch {
|
|
|
434
500
|
export interface SearchPopularLink {
|
|
435
501
|
/** Internal route or external URL. */
|
|
436
502
|
href: string;
|
|
437
|
-
/**
|
|
503
|
+
/**
|
|
504
|
+
* Icon shown beside the label — a built-in name, image path/URL, or inline
|
|
505
|
+
* SVG (same as nav icons). Defaults to the file glyph.
|
|
506
|
+
*/
|
|
438
507
|
icon?: string;
|
|
439
508
|
/** Link label shown in the dialog. */
|
|
440
509
|
label: string;
|
|
@@ -627,6 +696,12 @@ export interface LocaleConfigInput {
|
|
|
627
696
|
dir?: "ltr" | "rtl";
|
|
628
697
|
/** Human-readable name shown in the switcher. */
|
|
629
698
|
label: string;
|
|
699
|
+
/**
|
|
700
|
+
* Freeform style guidance for `blume translate`, e.g. "Brazilian
|
|
701
|
+
* Portuguese, informal você". Pins register and dialect from the first
|
|
702
|
+
* translation and wins over an existing translation's style on reruns.
|
|
703
|
+
*/
|
|
704
|
+
style?: string;
|
|
630
705
|
}
|
|
631
706
|
/**
|
|
632
707
|
* Internationalization. Opt-in: when omitted, Blume is single-locale. The
|
|
@@ -991,7 +1066,8 @@ export interface FrontmatterConfig {
|
|
|
991
1066
|
* so a required schema enforces the key site-wide; mark it `.optional()`
|
|
992
1067
|
* to validate only when present. Validated values are preserved on each
|
|
993
1068
|
* page record's `custom` field. Built-in frontmatter fields cannot be
|
|
994
|
-
* redeclared.
|
|
1069
|
+
* redeclared. To scope a key to one content type instead, declare it under
|
|
1070
|
+
* `content.types.<type>.frontmatter`.
|
|
995
1071
|
*/
|
|
996
1072
|
extend?: Record<string, StandardSchema>;
|
|
997
1073
|
}
|
|
@@ -24,6 +24,7 @@ declare const uiStringsObject: z.ZodObject<{
|
|
|
24
24
|
exportEpub: z.ZodDefault<z.ZodString>;
|
|
25
25
|
exportPdf: z.ZodDefault<z.ZodString>;
|
|
26
26
|
generating: z.ZodDefault<z.ZodString>;
|
|
27
|
+
openIn: z.ZodDefault<z.ZodString>;
|
|
27
28
|
openInChat: z.ZodDefault<z.ZodString>;
|
|
28
29
|
scrollToTop: z.ZodDefault<z.ZodString>;
|
|
29
30
|
}, z.core.$strip>>;
|
|
@@ -125,6 +126,7 @@ export declare const uiStringsSchema: z.ZodPrefault<z.ZodObject<{
|
|
|
125
126
|
exportEpub: z.ZodDefault<z.ZodString>;
|
|
126
127
|
exportPdf: z.ZodDefault<z.ZodString>;
|
|
127
128
|
generating: z.ZodDefault<z.ZodString>;
|
|
129
|
+
openIn: z.ZodDefault<z.ZodString>;
|
|
128
130
|
openInChat: z.ZodDefault<z.ZodString>;
|
|
129
131
|
scrollToTop: z.ZodDefault<z.ZodString>;
|
|
130
132
|
}, z.core.$strip>>;
|
|
@@ -207,6 +207,16 @@ declare const contentSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
207
207
|
}, z.core.$strip>], "type">;
|
|
208
208
|
/** A resolved content-source config entry (post-defaults). */
|
|
209
209
|
export type ContentSourceConfig = z.infer<typeof contentSourceSchema>;
|
|
210
|
+
/**
|
|
211
|
+
* A header label that may localize: a plain string, or a map of locale code to
|
|
212
|
+
* label (`{ en: "Docs", ja: "ドキュメント" }`). Resolved when each locale's
|
|
213
|
+
* navigation is built — the active locale's entry wins, then the default
|
|
214
|
+
* locale's, then the map's first entry — so a single-locale site can keep
|
|
215
|
+
* plain strings and an i18n site can translate its header without forking the
|
|
216
|
+
* config.
|
|
217
|
+
*/
|
|
218
|
+
declare const localizableLabelSchema: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
|
|
219
|
+
export type LocalizableLabel = z.infer<typeof localizableLabelSchema>;
|
|
210
220
|
declare const directoryModeSchema: z.ZodEnum<{
|
|
211
221
|
none: "none";
|
|
212
222
|
accordion: "accordion";
|
|
@@ -280,6 +290,7 @@ declare const localeSchema: z.ZodObject<{
|
|
|
280
290
|
rtl: "rtl";
|
|
281
291
|
}>>;
|
|
282
292
|
label: z.ZodString;
|
|
293
|
+
style: z.ZodOptional<z.ZodString>;
|
|
283
294
|
}, z.core.$strict>;
|
|
284
295
|
/**
|
|
285
296
|
* Internationalization. Opt-in: when absent, Blume is single-locale and behaves
|
|
@@ -297,6 +308,7 @@ declare const i18nConfigSchema: z.ZodObject<{
|
|
|
297
308
|
rtl: "rtl";
|
|
298
309
|
}>>;
|
|
299
310
|
label: z.ZodString;
|
|
311
|
+
style: z.ZodOptional<z.ZodString>;
|
|
300
312
|
}, z.core.$strict>>;
|
|
301
313
|
parser: z.ZodDefault<z.ZodEnum<{
|
|
302
314
|
dir: "dir";
|
|
@@ -531,6 +543,10 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
531
543
|
source: z.ZodCustom<ContentSource, ContentSource>;
|
|
532
544
|
type: z.ZodLiteral<"custom">;
|
|
533
545
|
}, z.core.$strip>], "type">>>;
|
|
546
|
+
types: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
547
|
+
facets: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
548
|
+
frontmatter: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodCustom<StandardSchema<unknown, unknown>, StandardSchema<unknown, unknown>>>>;
|
|
549
|
+
}, z.core.$strict>>>;
|
|
534
550
|
}, z.core.$strict>>;
|
|
535
551
|
dateFormat: z.ZodDefault<z.ZodObject<{
|
|
536
552
|
calendar: z.ZodOptional<z.ZodString>;
|
|
@@ -624,6 +640,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
624
640
|
rtl: "rtl";
|
|
625
641
|
}>>;
|
|
626
642
|
label: z.ZodString;
|
|
643
|
+
style: z.ZodOptional<z.ZodString>;
|
|
627
644
|
}, z.core.$strict>>;
|
|
628
645
|
parser: z.ZodDefault<z.ZodEnum<{
|
|
629
646
|
dir: "dir";
|
|
@@ -713,11 +730,11 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
713
730
|
items: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
714
731
|
description: z.ZodOptional<z.ZodString>;
|
|
715
732
|
icon: z.ZodOptional<z.ZodString>;
|
|
716
|
-
label: z.ZodString
|
|
733
|
+
label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
|
|
717
734
|
path: z.ZodString;
|
|
718
735
|
tag: z.ZodOptional<z.ZodString>;
|
|
719
736
|
}, z.core.$strict>>>;
|
|
720
|
-
label: z.ZodString
|
|
737
|
+
label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
|
|
721
738
|
path: z.ZodString;
|
|
722
739
|
}, z.core.$strict>>>;
|
|
723
740
|
}, z.core.$strict>>;
|
|
@@ -114,4 +114,9 @@ export interface NormalizeContext {
|
|
|
114
114
|
/** Opt-in custom frontmatter keys (`frontmatter.extend`), schema per key. */
|
|
115
115
|
frontmatterExtend?: FrontmatterExtend;
|
|
116
116
|
i18n?: ResolvedI18nConfig;
|
|
117
|
+
/**
|
|
118
|
+
* Per-type custom frontmatter keys (`content.types.<type>.frontmatter`),
|
|
119
|
+
* applied to a page only when its resolved `type` matches.
|
|
120
|
+
*/
|
|
121
|
+
typeFrontmatter?: Record<string, FrontmatterExtend>;
|
|
117
122
|
}
|
|
@@ -139,9 +139,10 @@ export interface PageRecord {
|
|
|
139
139
|
contentType: string;
|
|
140
140
|
meta: PageMeta;
|
|
141
141
|
/**
|
|
142
|
-
* Custom frontmatter values declared via `frontmatter.extend
|
|
143
|
-
* the user-supplied
|
|
144
|
-
*
|
|
142
|
+
* Custom frontmatter values declared via `frontmatter.extend` or the page
|
|
143
|
+
* type's `content.types.<type>.frontmatter`, validated by the user-supplied
|
|
144
|
+
* schemas (schema output, so transforms apply). Present only when the
|
|
145
|
+
* project opts in and the page carries at least one value.
|
|
145
146
|
*/
|
|
146
147
|
custom?: Record<string, unknown>;
|
|
147
148
|
headings: Heading[];
|
package/docs/02-deployment.mdx
CHANGED
|
@@ -101,7 +101,7 @@ On **Vercel**, **Netlify**, and **Cloudflare Pages**, Blume picks the matching a
|
|
|
101
101
|
|
|
102
102
|
A server build includes everything a static build does, plus any Astro endpoints or middleware you add. The `node` adapter produces a standalone server you can run directly.
|
|
103
103
|
|
|
104
|
-
On Vercel, a server build also turns on [`Accept: text/markdown` content negotiation](/docs/configuration/ai#content-negotiation)
|
|
104
|
+
On Vercel and Cloudflare, a server build also turns on [`Accept: text/markdown` content negotiation](/docs/configuration/ai#content-negotiation), so an agent requesting any content page with that header receives its raw-Markdown mirror at the same URL. On Vercel, Blume splices header-conditional rewrites into the deploy's routing config; on Cloudflare, it generates a small Worker in front of the Astro one and scopes `assets.run_worker_first` to the content routes, since the platform would otherwise serve the prerendered pages before any server code runs — other assets keep their zero-Worker fast path.
|
|
105
105
|
|
|
106
106
|
:::note
|
|
107
107
|
Server features have their own configuration — for example, Ask AI needs a model API key. See the [AI guide](/docs/configuration/ai) for setup.
|
|
@@ -63,7 +63,7 @@ The `.md` variant _downlevels_ components to plain Markdown for consumers that c
|
|
|
63
63
|
|
|
64
64
|
### Content negotiation
|
|
65
65
|
|
|
66
|
-
Agents don't need to know the `.md` convention: requesting a page's own URL with an [`Accept: text/markdown`](https://acceptmarkdown.com) header serves the Markdown variant at the same address, with `Vary: Accept` so caches keep the two apart. The dev server honors the header out of the box, and a [Vercel server build](/docs/deployment#server-rendering) wires the same negotiation into the deploy
|
|
66
|
+
Agents don't need to know the `.md` convention: requesting a page's own URL with an [`Accept: text/markdown`](https://acceptmarkdown.com) header serves the Markdown variant at the same address, with `Vary: Accept` so caches keep the two apart. The dev server honors the header out of the box, and a [Vercel or Cloudflare server build](/docs/deployment#server-rendering) wires the same negotiation into the deploy automatically — routing rules on Vercel, a generated Worker on Cloudflare — no configuration needed. The homepage always negotiates, even when it's a custom landing page rather than a content page: its Markdown mirror falls back to the [`llms.txt`](#llmstxt) index, so an agent asking the site root for Markdown gets the machine-readable map of the site. Markdown responses also carry an `x-markdown-tokens` header — an estimated token count (~4 characters per token), following the convention of [Cloudflare's Markdown for Agents](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/) — on every surface where Blume controls response headers: the dev server, server-rendered responses, and the negotiated homepage on Vercel and Cloudflare. Other deploy targets serve prerendered pages from a static layer with no request-time hook, so agents there fetch the `.md` URL directly; the [agent readability manifest](#agent-readability) advertises `contentNegotiation` only on deployments that honor the header.
|
|
67
67
|
|
|
68
68
|
### Custom component serializers
|
|
69
69
|
|
|
@@ -257,6 +257,20 @@ The server exposes read-only tools — `search_docs`, `get_page`, `list_pages`,
|
|
|
257
257
|
|
|
258
258
|
`search_docs` runs its own full-text index, so it works regardless of your [search](/docs/configuration/search) provider — and even when search is set to `none`. The MCP server is a separate feature from on-page search.
|
|
259
259
|
|
|
260
|
+
`search_docs` and `list_pages` both accept an optional `contentTypes` filter, narrowing results to pages of the given frontmatter [`type`s](/docs/reference/frontmatter) — `["rfc"]`, `["blog", "changelog"]` — so an agent working against a site that mixes docs with RFCs, runbooks, or policies can scope retrieval to the kind of page it needs. Every result names its content type, and `list_pages` output shows the types in use.
|
|
261
|
+
|
|
262
|
+
Both tools also accept a `filters` object matching against the facets a site declares per content type ([`content.types.<type>.facets`](/docs/configuration#frontmatter)) — custom frontmatter keys whose values become filterable metadata:
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"query": "OpenAPI request schemas",
|
|
267
|
+
"contentTypes": ["rfc"],
|
|
268
|
+
"filters": { "domain": "architecture", "status": "enforced" }
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Every `filters` entry must match (results carry their facet values, and `list_pages` shows each page's), so a knowledge base can drive progressive-disclosure agent workflows — enumerate the enforced standards, search only within them — without any server of its own.
|
|
273
|
+
|
|
260
274
|
### Server output required
|
|
261
275
|
|
|
262
276
|
The MCP server is a live endpoint (`/mcp`), so it can't run on a static build. Switch to server output and pick an adapter:
|
|
@@ -189,6 +189,7 @@ content: {
|
|
|
189
189
|
| `exclude` | `["**/_*", "**/.*"]` | Globs to ignore (underscore- and dot-files). |
|
|
190
190
|
| `pages` | `"pages"` | Folder for custom `.astro` pages. |
|
|
191
191
|
| `defaultType` | `"doc"` | Page `type` used when frontmatter omits it. |
|
|
192
|
+
| `types` | `{}` | Per-type content definitions — custom frontmatter keys scoped to pages of one `type`. See [Frontmatter](#frontmatter). |
|
|
192
193
|
|
|
193
194
|
Static assets live in `public/` — a file at `public/logo.png` is served at `/logo.png`, so a reference like `` resolves against `public/images/create.png`. Images referenced by **relative path** (``) live next to your content instead, and are [optimized at build time](/docs/content/syntax#links-and-images).
|
|
194
195
|
|
|
@@ -230,6 +231,31 @@ export default defineConfig({
|
|
|
230
231
|
|
|
231
232
|
Any [Standard Schema](https://standardschema.dev) library works — Zod (whichever version your project installs), Valibot, ArkType. Keys outside the extension stay strictly validated, so typo-catching is unchanged. See [Custom keys](/docs/reference/frontmatter#custom-keys) for the validation semantics.
|
|
232
233
|
|
|
234
|
+
Keys under `extend` apply site-wide. To require keys only on pages of one content type — an RFC's `status`, a runbook's `service` — declare them per type under `content.types` instead:
|
|
235
|
+
|
|
236
|
+
```ts blume.config.ts lineNumbers
|
|
237
|
+
import { defineConfig } from "blume";
|
|
238
|
+
import { z } from "zod";
|
|
239
|
+
|
|
240
|
+
export default defineConfig({
|
|
241
|
+
content: {
|
|
242
|
+
types: {
|
|
243
|
+
rfc: {
|
|
244
|
+
facets: ["domain", "status"],
|
|
245
|
+
frontmatter: {
|
|
246
|
+
domain: z.string(),
|
|
247
|
+
status: z.enum(["draft", "review", "enforced"]),
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
A key can be declared site-wide or per-type, not both. See [Per-type keys](/docs/reference/frontmatter#per-type-keys) for how the scoping resolves.
|
|
256
|
+
|
|
257
|
+
`facets` names the custom keys whose values become filterable metadata: they ride along on search documents (`blume-search.json` and the MCP index), and the [MCP tools](/docs/configuration/ai#mcp-server) accept a `filters` input matching against them, so an agent can retrieve, say, only `enforced` RFCs in the `architecture` domain. Each facet must be a declared custom key — per-type or site-wide — and only string (or stringified number/boolean) values facet.
|
|
258
|
+
|
|
233
259
|
## GitHub
|
|
234
260
|
|
|
235
261
|
Point Blume at your repository with `github`. It powers the header [repository link](/docs/content/navigation#repository-link) and the **Edit on GitHub** and **Give feedback** [page actions](/docs/content/navigation#page-actions):
|
|
@@ -27,9 +27,7 @@ search: {
|
|
|
27
27
|
},
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Each entry takes an `href` (internal route or external URL) and a `label`, plus an optional `icon` — a built-in icon name
|
|
31
|
-
|
|
32
|
-
Unlike icons elsewhere in Blume, `icon` here must be a built-in name: these rows render in a client island, so an image path or inline SVG isn't supported and falls back to the file glyph.
|
|
30
|
+
Each entry takes an `href` (internal route or external URL) and a `label`, plus an optional `icon` — a [built-in icon](/docs/content/components#icon) name, image path/URL, or inline SVG (same _inputs_ as nav icons), defaulting to a file glyph. Omit `popular` or leave it empty to keep the sidebar fallback.
|
|
33
31
|
|
|
34
32
|
Write `href` as if the site were mounted at the root — a `basePath` is applied for you, the same as `navigation.featured`. External URLs pass through untouched.
|
|
35
33
|
|
package/docs/content/i18n.mdx
CHANGED
|
@@ -20,7 +20,7 @@ i18n: {
|
|
|
20
20
|
}
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Each locale has a `code` (used in URLs), a `label` (shown in the language switcher), and an optional `dir` for right-to-left scripts (`"ltr"` by default).
|
|
23
|
+
Each locale has a `code` (used in URLs), a `label` (shown in the language switcher), and an optional `dir` for right-to-left scripts (`"ltr"` by default). An optional `style` gives [`blume translate`](/docs/reference/translate) freeform guidance for the locale — register, dialect, terminology, e.g. `"Brazilian Portuguese, informal você"` — so the choice is pinned from the very first translation instead of decided by the agent.
|
|
24
24
|
|
|
25
25
|
## Organize translated content
|
|
26
26
|
|
|
@@ -83,6 +83,8 @@ i18n: {
|
|
|
83
83
|
|
|
84
84
|
Each language gets its own sidebar, built from that locale's files — so translations can diverge in structure, ordering, or labels. Folder [`meta.ts`](/docs/content/meta) files resolve per locale, too: under the default `dir` parser, put a `meta.ts` under `fr/guides/` to order the French group independently. Under the `dot` parser translations sit next to the originals, so a folder's `meta.ts` applies to every locale. Everything else about [navigation](/docs/content/navigation) works the same, per language.
|
|
85
85
|
|
|
86
|
+
Header tabs are configured, not derived from content, so their labels localize in `blume.config.ts`: a tab `label` accepts a per-locale map (`{ en: "Docs", fr: "Documentation" }`) alongside the plain-string form, falling back to the default locale's entry for locales you haven't filled in. See [Tabs](/docs/content/navigation#tabs).
|
|
87
|
+
|
|
86
88
|
## Fallbacks
|
|
87
89
|
|
|
88
90
|
When a page isn't translated yet, Blume renders the fallback locale's content at the localized URL — so the link works, the page is fully pre-rendered, and search engines aren't sent to a dead end. The fallback defaults to your `defaultLocale`:
|
|
@@ -100,6 +102,16 @@ Fallback pages are excluded from the search index and aren't advertised as real
|
|
|
100
102
|
Start by translating your most important pages — the homepage, quickstart, and top guides — and let the rest fall back. You can fill in translations over time without breaking any links.
|
|
101
103
|
:::
|
|
102
104
|
|
|
105
|
+
## Translating with an agent
|
|
106
|
+
|
|
107
|
+
You don't have to fill in the locales by hand. [`blume translate`](/docs/reference/translate) finds every page that's missing or outdated in each locale and translates it with a local agent CLI ([Claude Code](https://claude.com/claude-code) or [Codex](https://developers.openai.com/codex/cli)):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
blume translate --claude
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Blume validates each result's structure — frontmatter, code fences, links — and writes the files itself; the agent only translates text. A committed ledger (`blume.translations.json`) tracks which source revision each translation came from, so reruns only touch what changed, and translations you wrote by hand are adopted as-is, never overwritten. In CI, `blume translate --check` fails when a source page has drifted ahead of its translations.
|
|
114
|
+
|
|
103
115
|
## The language switcher
|
|
104
116
|
|
|
105
117
|
When i18n is on, a language switcher appears in the header automatically, generated from your `locales`. For each page it links the matching translation in every language; where a translation is missing it links the fallback page and marks it as not translated. There's nothing to configure.
|
|
@@ -135,6 +135,17 @@ navigation: {
|
|
|
135
135
|
|
|
136
136
|
This matters for routes that aren't part of the content tree, since the fallback can't see them: the generated [changelog](/docs/advanced/changelog) index, or a [custom page](/docs/advanced/custom-pages) you added under `pages/`. Without `href`, a `/changelog` tab lands on the newest entry rather than the index. Tabs that don't set `href` are unaffected.
|
|
137
137
|
|
|
138
|
+
On an [i18n](/docs/content/i18n) site, a tab's `label` (and a dropdown item's) can be a per-locale map instead of a string — the active locale's entry wins, then the default locale's:
|
|
139
|
+
|
|
140
|
+
```ts blume.config.ts
|
|
141
|
+
navigation: {
|
|
142
|
+
tabs: [
|
|
143
|
+
{ label: { en: "Docs", fr: "Documentation" }, path: "/docs" },
|
|
144
|
+
{ label: "CLI", path: "/cli" }, // a plain string renders as-is everywhere
|
|
145
|
+
],
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
138
149
|
Tabs also **scope the sidebar**: when the current route falls under a tab's `path`, the sidebar shows only that section's pages — so `/adapters/*` lists the adapters and nothing else. The folder at a tab's `path` becomes the section, so this needs no extra config beyond the tabs themselves; structure your content into a folder per tab and point each tab at it.
|
|
139
150
|
|
|
140
151
|
On a route under no tab (or a tab whose `path` is `/`), the sidebar shows the pages that _don't_ belong to a tab — each tab's folder is hidden from it, since that section already has its own tab in the header. So a root landing page lists your loose top-level pages while the sectioned content stays behind its tab, mirroring Fumadocs' root folders. If a route has no pages of its own to show this way, the full tree is shown instead, so the sidebar is never left blank.
|
package/docs/reference/cli.mdx
CHANGED
|
@@ -23,6 +23,7 @@ blume <command> [options]
|
|
|
23
23
|
| `blume validate` | Validate links across your content. |
|
|
24
24
|
| `blume audit` | Audit the built site for SEO and health issues. |
|
|
25
25
|
| `blume eval` | Test the docs: an agent answers your questions using only the documentation. |
|
|
26
|
+
| `blume translate` | Translate docs into the configured locales with a local agent CLI. |
|
|
26
27
|
|
|
27
28
|
## Common flags
|
|
28
29
|
|
|
@@ -62,6 +63,9 @@ blume <command> [options]
|
|
|
62
63
|
- `blume eval` — run the questions in `evals.yaml` through an agent that reads only your docs; see [Evals](/docs/reference/eval).
|
|
63
64
|
- `blume eval init` — have the agent draft a starter `evals.yaml` from your docs.
|
|
64
65
|
- `blume eval --agent claude|codex --threshold <0..1> --timeout <seconds> --json --fix --verbose` — see [Evals](/docs/reference/eval) for each flag.
|
|
66
|
+
- `blume translate --claude` / `--codex` — translate missing and outdated pages into your configured locales; see [Translate](/docs/reference/translate).
|
|
67
|
+
- `blume translate --check` — report translation drift and exit non-zero (the CI gate), without running an agent.
|
|
68
|
+
- `blume translate --locale <codes> --concurrency <n> --force --timeout <seconds> --json` — see [Translate](/docs/reference/translate) for each flag.
|
|
65
69
|
|
|
66
70
|
## Verifying while the dev server runs
|
|
67
71
|
|
|
@@ -109,6 +109,39 @@ reviewedAt: 2026-06-20
|
|
|
109
109
|
|
|
110
110
|
Schemas are accepted through the [Standard Schema](https://standardschema.dev) interface, so Zod (whichever version your project installs), Valibot, and ArkType all work. Every declared key is validated on every page — absent ones included — so a required schema enforces the key site-wide; mark it `.optional()` to validate only where present. All other keys stay strictly validated, and built-in fields can't be redeclared.
|
|
111
111
|
|
|
112
|
+
### Per-type keys
|
|
113
|
+
|
|
114
|
+
To require keys only on one content type — an RFC's `status`, an incident report's `severity` — declare them under [`content.types`](/docs/configuration#content) instead, keyed by the frontmatter `type` they apply to:
|
|
115
|
+
|
|
116
|
+
```ts blume.config.ts lineNumbers
|
|
117
|
+
import { defineConfig } from "blume";
|
|
118
|
+
import { z } from "zod";
|
|
119
|
+
|
|
120
|
+
export default defineConfig({
|
|
121
|
+
content: {
|
|
122
|
+
types: {
|
|
123
|
+
rfc: {
|
|
124
|
+
frontmatter: {
|
|
125
|
+
domain: z.string(),
|
|
126
|
+
status: z.enum(["draft", "review", "enforced"]),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```yaml rfcs/openapi-request-schemas.mdx
|
|
135
|
+
---
|
|
136
|
+
title: OpenAPI request schemas
|
|
137
|
+
type: rfc
|
|
138
|
+
domain: architecture
|
|
139
|
+
status: enforced
|
|
140
|
+
---
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Per-type keys follow the same validation rules as `extend`, scoped to pages whose resolved `type` matches — including pages that set no `type`, when the declaration is for [`content.defaultType`](/docs/configuration#content). A key belongs to one declaration, site-wide or per-type, not both. And a key declared only for another type stays unknown elsewhere, so a stray `status` on a plain doc page still fails the build.
|
|
144
|
+
|
|
112
145
|
A page that fails validation fails `blume build` with a diagnostic naming the file and key. With [`--no-strict`](/docs/reference/cli#common-flags), the build succeeds anyway and the failing pages are dropped from the output — the build summary reports how many.
|
|
113
146
|
|
|
114
147
|
Schemas are exported from `blume/schema` for editor and migration tooling.
|
package/docs/reference/meta.ts
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Translate
|
|
3
|
+
description: blume translate fills in your locales with an AI agent — it finds the pages that are missing or outdated in each language, translates them with the agent CLI you already have, and gives CI a gate that fails when translations drift.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Once [i18n](/docs/content/i18n) is on, every edit to a source page quietly outdates its translations. `blume translate` closes that loop: it computes exactly which pages are missing or stale in each locale, translates them headlessly with a local agent CLI, and records what it did in a committed ledger so the next run — and CI — knows what's current.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
blume translate --claude
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
blume translate 3 item(s) · 2 locale(s) · Claude Code
|
|
14
|
+
|
|
15
|
+
✔ docs/guides/install.mdx → fr 24.2s $0.11
|
|
16
|
+
✔ docs/guides/install.mdx → de 22.8s $0.10
|
|
17
|
+
✔ meta titles (2) → de 4.1s $0.01
|
|
18
|
+
|
|
19
|
+
Translated 3 files into 2 locales · 1 adopted · 14 already up to date · 51.1s · $0.22
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## How it works
|
|
23
|
+
|
|
24
|
+
Blume owns the pipeline; the agent only translates text. For each file that needs work, Blume builds a translation prompt, runs the agent CLI headlessly with its file, shell, and web tools disabled, validates the reply's structure, and writes the target file itself — [Claude Code](https://claude.com/claude-code) with `--claude`, or [Codex](https://developers.openai.com/codex/cli) with `--codex`. Blume holds no API keys and calls no model itself.
|
|
25
|
+
|
|
26
|
+
Every validated write is recorded in `blume.translations.json` at the project root: for each source file and locale, a hash of the source at the moment it was translated. **Commit this file.** It's how a rerun knows the difference between "already translated" and "translated, but the source changed since" — and it's what makes the CI gate possible.
|
|
27
|
+
|
|
28
|
+
The ledger is flushed after every finished file, so stopping a long run (Ctrl+C) loses at most the translations that were in flight — the next run picks up where you left off. Files run 4 at a time by default; raise it with `--concurrency` if your machine and the agent's rate limits allow.
|
|
29
|
+
|
|
30
|
+
Reruns are incremental: a source that hasn't changed since its last translation is skipped, so running `blume translate` after editing one page translates one page per locale. When a stale page is retranslated, the agent is shown the existing translation and told to match its register, dialect, and terminology — a one-paragraph source edit produces a one-paragraph translation diff, not a from-scratch rewrite.
|
|
31
|
+
|
|
32
|
+
A first translation has no precedent to match, so pin the choice up front with [`style` on the locale](/docs/content/i18n) (`{ code: "pt", label: "Português", style: "Brazilian Portuguese, informal você" }`). The guidance rides along in every translation prompt, and where an existing translation disagrees with it, `style` wins — so a retranslation also nudges older pages toward the configured style.
|
|
33
|
+
|
|
34
|
+
## What gets translated
|
|
35
|
+
|
|
36
|
+
- **Pages** — `.md`/`.mdx` files in the default locale. The agent translates the prose and only the human-visible frontmatter values (`title`, `description`, `sidebar.label`, `sidebar.badge`, `seo.title`, `seo.description`). Targets follow your parser: `fr/guides/install.mdx` under `dir`, `guides/install.fr.mdx` under `dot`.
|
|
37
|
+
- **Folder navigation titles** — under the `dir` parser, each locale's needed [`meta.ts`](/docs/content/meta) titles are translated in one batched call, and the generated per-locale `meta.ts` copies every other key (`order`, `pages`, `icon`, `collapsed`) verbatim so the locale's sidebar keeps its ordering.
|
|
38
|
+
|
|
39
|
+
Translations you wrote by hand are **adopted, never overwritten**: a translation that exists but has no ledger entry is stamped as current and left alone. Only `--force` retranslates it.
|
|
40
|
+
|
|
41
|
+
## Validation
|
|
42
|
+
|
|
43
|
+
The agent is never trusted with structure. Before writing, Blume checks each reply and rebuilds the file from the source:
|
|
44
|
+
|
|
45
|
+
- The frontmatter is reconstructed from the source file's data, with only the six translatable values overlaid — keys the agent invented are dropped, keys it deleted are restored, and `slug`, `icon`, `order`, and dates are source-verbatim by construction.
|
|
46
|
+
- The number of code fences must match the source, the body must be non-empty, and the frontmatter must parse.
|
|
47
|
+
|
|
48
|
+
A reply that fails validation writes nothing — the item is reported as failed and the run moves on. Everything that succeeded stays stamped in the ledger, so a rerun retries only the failures.
|
|
49
|
+
|
|
50
|
+
## Failing CI
|
|
51
|
+
|
|
52
|
+
`blume translate --check` is the read-only gate: it reports every missing and stale pair and exits non-zero when there's drift, without running an agent or writing anything.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
blume translate --check # exit 1 when translations are missing or stale
|
|
56
|
+
blume translate --check --json # machine-readable drift report on stdout
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```yaml .github/workflows/translations.yml
|
|
60
|
+
- run: npx blume translate --check
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The JSON report carries the same `diagnostics` + `summary` shape as `blume validate --json`, `blume audit --json`, and `blume eval --json`, with the drift grouped per locale. Hand-authored (untracked) translations never fail the gate.
|
|
64
|
+
|
|
65
|
+
## Limitations
|
|
66
|
+
|
|
67
|
+
- Meta title translation is `dir`-parser only — the `dot` parser has no per-locale `meta.ts` mechanism. A `meta.ts` that default-exports a function is skipped with a warning; author that locale's copy by hand.
|
|
68
|
+
- Remote and CMS-backed sources are skipped: there is no local file to write the translation to.
|
|
69
|
+
- Header tab labels live in `blume.config.ts`, not content — localize them there with [per-locale label maps](/docs/content/navigation#tabs).
|
|
70
|
+
- Translation quality is the agent's. Review the output like any other contribution — the ledger only guarantees freshness, not fluency.
|
|
71
|
+
|
|
72
|
+
## Flags
|
|
73
|
+
|
|
74
|
+
- `--claude` / `--codex` — which agent CLI translates. Exactly one is required (except with `--check`).
|
|
75
|
+
- `--check` — report drift and exit non-zero, without writing anything.
|
|
76
|
+
- `--concurrency <n>` — parallel agent sessions. Defaults to `4`, max `16`.
|
|
77
|
+
- `--locale <codes>` — comma-separated target locales (defaults to every non-default locale).
|
|
78
|
+
- `--force` — retranslate everything, up-to-date and hand-authored files included.
|
|
79
|
+
- `--timeout <seconds>` — agent time limit per file. Defaults to `600`; the ceiling exists to catch hung agents, so large pages have room to finish.
|
|
80
|
+
- `--json` — emit the report as JSON on stdout, in both modes.
|
package/package.json
CHANGED
|
@@ -93,12 +93,15 @@ export const buildAgentReadability = (
|
|
|
93
93
|
|
|
94
94
|
// Advertise `Accept: text/markdown` negotiation only where the deployed site
|
|
95
95
|
// actually honors it — a Vercel server build, whose routing config gets the
|
|
96
|
-
// rewrite rules (see `deploy/vercel-negotiation.ts`)
|
|
97
|
-
//
|
|
98
|
-
//
|
|
96
|
+
// rewrite rules (see `deploy/vercel-negotiation.ts`), and a Cloudflare server
|
|
97
|
+
// build, whose deploy bundle gets a wrapper Worker (see
|
|
98
|
+
// `deploy/cloudflare-negotiation.ts`). Static builds and other adapters serve
|
|
99
|
+
// prerendered pages from a static layer with no request-time hook, so agents
|
|
100
|
+
// there should fetch the `.md` pattern directly.
|
|
99
101
|
const negotiates =
|
|
100
102
|
config.deployment.output === "server" &&
|
|
101
|
-
config.deployment.adapter === "vercel"
|
|
103
|
+
(config.deployment.adapter === "vercel" ||
|
|
104
|
+
config.deployment.adapter === "cloudflare");
|
|
102
105
|
const artifacts: Record<string, unknown> = {
|
|
103
106
|
markdown: {
|
|
104
107
|
...(negotiates ? { contentNegotiation: "text/markdown" } : {}),
|
package/src/ai/ask-context.ts
CHANGED
|
@@ -226,12 +226,9 @@ export const createAskContext = (
|
|
|
226
226
|
? byRoute.get(normalizeRoute(page.path))
|
|
227
227
|
: undefined;
|
|
228
228
|
const db = await index();
|
|
229
|
-
const hits = await queryOramaIndex(
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
MAX_RESULTS,
|
|
233
|
-
current?.locale || undefined
|
|
234
|
-
);
|
|
229
|
+
const hits = await queryOramaIndex(db, query, MAX_RESULTS, {
|
|
230
|
+
locale: current?.locale || undefined,
|
|
231
|
+
});
|
|
235
232
|
|
|
236
233
|
const seen = new Set<string>();
|
|
237
234
|
const sections: string[] = [];
|