blume 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli/index.js +3512 -814
  3. package/dist/cli/index.js.map +99 -87
  4. package/dist/types/core/base-path.d.ts +5 -0
  5. package/dist/types/core/config-input.d.ts +82 -6
  6. package/dist/types/core/i18n-ui.d.ts +2 -0
  7. package/dist/types/core/schema.d.ts +19 -2
  8. package/dist/types/core/sources/types.d.ts +5 -0
  9. package/dist/types/core/types.d.ts +4 -3
  10. package/docs/02-deployment.mdx +1 -1
  11. package/docs/configuration/ai.mdx +15 -1
  12. package/docs/configuration/index.mdx +26 -0
  13. package/docs/configuration/search.mdx +1 -3
  14. package/docs/content/i18n.mdx +13 -1
  15. package/docs/content/navigation.mdx +11 -0
  16. package/docs/reference/cli.mdx +4 -0
  17. package/docs/reference/frontmatter.mdx +33 -0
  18. package/docs/reference/meta.ts +1 -1
  19. package/docs/reference/translate.mdx +80 -0
  20. package/package.json +22 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/component-markdown.ts +7 -6
  24. package/src/ai/mcp/data.ts +10 -4
  25. package/src/ai/mcp/server.ts +74 -3
  26. package/src/ai/mcp/tools.ts +2 -2
  27. package/src/astro/generate.ts +4 -13
  28. package/src/astro/integration.ts +3 -1
  29. package/src/astro/islands.ts +4 -1
  30. package/src/astro/markdown-negotiation.ts +5 -0
  31. package/src/astro/templates.ts +69 -22
  32. package/src/audit/checks/indexability.ts +3 -6
  33. package/src/audit/checks/robots.ts +18 -37
  34. package/src/audit/crawl.ts +49 -49
  35. package/src/audit/image-size.ts +13 -53
  36. package/src/audit/report.ts +22 -33
  37. package/src/audit/types.ts +6 -2
  38. package/src/audit/url.ts +5 -10
  39. package/src/cli/commands/build.ts +129 -24
  40. package/src/cli/commands/dev.ts +9 -21
  41. package/src/cli/commands/doctor.ts +9 -22
  42. package/src/cli/commands/translate.ts +300 -0
  43. package/src/cli/env.ts +6 -52
  44. package/src/cli/index.ts +2 -0
  45. package/src/cli/init/scaffold.ts +15 -28
  46. package/src/cli/internal-error.ts +11 -11
  47. package/src/components/Icon.astro +2 -7
  48. package/src/components/content/Step.astro +3 -8
  49. package/src/components/content/Tab.astro +20 -1
  50. package/src/components/islands/ask-ai.tsx +25 -100
  51. package/src/components/islands/hooks.ts +10 -3
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +4 -4
  54. package/src/components/layout/PageActions.astro +12 -7
  55. package/src/components/layout/RootLayout.astro +37 -109
  56. package/src/components/layout/Search.astro +18 -25
  57. package/src/components/layout/search/orama.ts +3 -1
  58. package/src/components/layout/search/types.ts +4 -16
  59. package/src/components/openapi/helpers.ts +21 -75
  60. package/src/core/base-path.ts +9 -0
  61. package/src/core/component-overrides.ts +0 -7
  62. package/src/core/config-input.ts +84 -6
  63. package/src/core/config.ts +3 -3
  64. package/src/core/diagnostics.ts +10 -20
  65. package/src/core/fs-atomic.ts +22 -0
  66. package/src/core/graph.ts +46 -2
  67. package/src/core/i18n-ui.ts +2 -0
  68. package/src/core/i18n.ts +31 -0
  69. package/src/core/nav-diagnostics.ts +13 -34
  70. package/src/core/project-graph.ts +13 -2
  71. package/src/core/schema.ts +174 -74
  72. package/src/core/sources/github-releases.ts +29 -26
  73. package/src/core/sources/mdx-remote.ts +10 -57
  74. package/src/core/sources/normalize.ts +25 -12
  75. package/src/core/sources/notion.ts +17 -23
  76. package/src/core/sources/types.ts +5 -0
  77. package/src/core/tsconfig-aliases.ts +39 -172
  78. package/src/core/types.ts +4 -3
  79. package/src/core/ui-packs/ar.ts +42 -1
  80. package/src/core/ui-packs/bg.ts +42 -1
  81. package/src/core/ui-packs/bn.ts +42 -1
  82. package/src/core/ui-packs/ca.ts +44 -1
  83. package/src/core/ui-packs/cs.ts +42 -1
  84. package/src/core/ui-packs/da.ts +42 -1
  85. package/src/core/ui-packs/de.ts +42 -1
  86. package/src/core/ui-packs/el.ts +44 -1
  87. package/src/core/ui-packs/es.ts +44 -1
  88. package/src/core/ui-packs/fa.ts +42 -1
  89. package/src/core/ui-packs/fi.ts +42 -1
  90. package/src/core/ui-packs/fr.ts +44 -1
  91. package/src/core/ui-packs/he.ts +42 -1
  92. package/src/core/ui-packs/hi.ts +42 -1
  93. package/src/core/ui-packs/hr.ts +42 -1
  94. package/src/core/ui-packs/hu.ts +42 -1
  95. package/src/core/ui-packs/id.ts +42 -1
  96. package/src/core/ui-packs/it.ts +44 -1
  97. package/src/core/ui-packs/ja.ts +44 -1
  98. package/src/core/ui-packs/ko.ts +44 -1
  99. package/src/core/ui-packs/nl.ts +42 -1
  100. package/src/core/ui-packs/no.ts +42 -1
  101. package/src/core/ui-packs/pl.ts +42 -1
  102. package/src/core/ui-packs/pt-br.ts +44 -1
  103. package/src/core/ui-packs/pt.ts +44 -1
  104. package/src/core/ui-packs/ro.ts +42 -1
  105. package/src/core/ui-packs/ru.ts +42 -1
  106. package/src/core/ui-packs/sk.ts +42 -1
  107. package/src/core/ui-packs/sr.ts +42 -1
  108. package/src/core/ui-packs/sv.ts +42 -1
  109. package/src/core/ui-packs/th.ts +44 -1
  110. package/src/core/ui-packs/tr.ts +42 -1
  111. package/src/core/ui-packs/uk.ts +42 -1
  112. package/src/core/ui-packs/vi.ts +44 -1
  113. package/src/core/ui-packs/zh-tw.ts +44 -1
  114. package/src/core/ui-packs/zh.ts +44 -1
  115. package/src/deploy/adapter-output.ts +44 -5
  116. package/src/deploy/cloudflare-negotiation.ts +527 -0
  117. package/src/deploy/redirects.ts +13 -0
  118. package/src/deploy/rss.ts +4 -1
  119. package/src/deploy/sitemap.ts +3 -1
  120. package/src/eval/agents.ts +1 -1
  121. package/src/eval/report.ts +20 -28
  122. package/src/markdown/directives.ts +6 -18
  123. package/src/markdown/index.ts +1 -6
  124. package/src/markdown/package-commands.ts +0 -4
  125. package/src/openapi/parse.ts +11 -9
  126. package/src/search/documents.ts +11 -0
  127. package/src/search/facets.ts +33 -0
  128. package/src/search/orama-index.ts +48 -6
  129. package/src/search/popular-icon.ts +33 -0
  130. package/src/theme/icon-kind.ts +20 -0
  131. package/src/translate/agents.ts +51 -0
  132. package/src/translate/ledger.ts +142 -0
  133. package/src/translate/meta.ts +149 -0
  134. package/src/translate/prompts.ts +95 -0
  135. package/src/translate/report.ts +354 -0
  136. package/src/translate/run.ts +357 -0
  137. package/src/translate/validate.ts +171 -0
  138. package/src/translate/work-list.ts +0 -0
  139. package/src/deploy/xml.ts +0 -8
@@ -1,9 +1,11 @@
1
+ import { sample } from "openapi-sampler";
2
+
1
3
  /**
2
4
  * Runtime helpers for the OpenAPI components. These operate on the parsed spec
3
5
  * behind the `blume:openapi` alias — resolving `$ref`s (kept intact at parse
4
6
  * time to avoid circular graphs), labelling types, and generating request
5
- * examples and code samples. Pure and dependency-free so they run in the browser
6
- * build with no server-only imports.
7
+ * examples and code samples. Browser-safe (no server-only imports); example
8
+ * values come from openapi-sampler, which is likewise browser-safe.
7
9
  */
8
10
 
9
11
  /** A permissive view of an OpenAPI 3.1 schema — only the fields we render. */
@@ -226,88 +228,32 @@ export const objectProperties = (
226
228
  return { properties: [...properties.entries()], required };
227
229
  };
228
230
 
229
- /** Sentinel: no explicit example is declared on a schema. */
230
- const NO_VALUE = Symbol("no-value");
231
-
232
- /** The declared example/const/default/enum for a schema, or {@link NO_VALUE}. */
233
- const explicitExample = (schema: SchemaLike): unknown => {
234
- if (schema.example !== undefined) {
235
- return schema.example;
236
- }
237
- if (Array.isArray(schema.examples) && schema.examples.length > 0) {
238
- return schema.examples[0];
239
- }
240
- // `const` is the schema's only valid value (the 3.1 discriminator idiom), so
241
- // it outranks `default`/`enum` — either of those differing would be invalid.
242
- if (schema.const !== undefined) {
243
- return schema.const;
244
- }
245
- if (schema.default !== undefined) {
246
- return schema.default;
247
- }
248
- if (Array.isArray(schema.enum) && schema.enum.length > 0) {
249
- return schema.enum[0];
250
- }
251
- return NO_VALUE;
252
- };
253
-
254
- /** A placeholder value for a primitive (leaf) schema. */
255
- const primitiveExample = (
256
- types: string[],
257
- format: string | undefined
258
- ): unknown => {
259
- if (types.includes("number") || types.includes("integer")) {
260
- return 0;
261
- }
262
- if (types.includes("boolean")) {
263
- return true;
264
- }
265
- if (format === "date-time") {
266
- return "2024-01-01T00:00:00Z";
267
- }
268
- return format ? `<${format}>` : "string";
269
- };
270
-
271
231
  /**
272
- * Build a representative example value for a schema (honoring `example` /
273
- * `const` / `default` / `enum` first). A `seen` set of `$ref`s guards against
274
- * the circular schemas that keeping refs intact allows.
232
+ * Build a representative example value for a schema via openapi-sampler
233
+ * (Redoc's generator): declared `example`/`const`/`default`/`enum` values
234
+ * win, formats produce realistic placeholders (`email`, `uuid`, `date-time`),
235
+ * `readOnly` fields are skipped (these samples illustrate *requests*, and a
236
+ * server-generated field has no place in one), and circular `$ref` chains —
237
+ * which keeping refs intact allows — terminate safely.
275
238
  */
276
239
  export const exampleValue = (
277
240
  schema: SchemaLike | undefined,
278
- schemas: Record<string, SchemaLike>,
279
- seen = new Set<string>()
241
+ schemas: Record<string, SchemaLike>
280
242
  ): unknown => {
281
243
  if (!schema) {
282
244
  return null;
283
245
  }
284
- if (typeof schema.$ref === "string") {
285
- if (seen.has(schema.$ref)) {
286
- return null;
287
- }
288
- seen.add(schema.$ref);
289
- return exampleValue(resolveSchema(schemas, schema), schemas, seen);
290
- }
291
- const explicit = explicitExample(schema);
292
- if (explicit !== NO_VALUE) {
293
- return explicit;
294
- }
295
- const branch = schema.oneOf?.[0] ?? schema.anyOf?.[0];
296
- if (branch) {
297
- return exampleValue(branch, schemas, seen);
298
- }
299
- const types = nonNullTypes(schema.type);
300
- if (types.includes("array")) {
301
- return [exampleValue(schema.items, schemas, seen)];
302
- }
303
- if (types.includes("object") || schema.properties || schema.allOf) {
304
- const out: Record<string, unknown> = {};
305
- for (const [name, prop] of objectProperties(schema, schemas).properties) {
306
- out[name] = exampleValue(prop, schemas, new Set(seen));
307
- }
308
- return out;
246
+ try {
247
+ return sample(
248
+ schema as Parameters<typeof sample>[0],
249
+ { quiet: true, skipReadOnly: true },
250
+ { components: { schemas } }
251
+ );
252
+ } catch {
253
+ // An unresolvable $ref or malformed schema is a spec problem the schema
254
+ // tables already surface; a sample is best-effort.
255
+ return null;
309
256
  }
310
- return primitiveExample(types, schema.format);
311
257
  };
312
258
 
313
259
  /** Pretty-print a JSON value for an example/code block. */
@@ -28,6 +28,15 @@ export const normalizeBasePath = (input?: string): string => {
28
28
  return trimmed === "" ? "" : `/${trimmed}`;
29
29
  };
30
30
 
31
+ /**
32
+ * Normalize a served path for comparison: drop the trailing slash (Astro serves
33
+ * `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
34
+ */
35
+ export const normalizePath = (path: string): string => {
36
+ const trimmed = path.replace(/\/+$/u, "");
37
+ return trimmed === "" ? "/" : trimmed;
38
+ };
39
+
31
40
  /**
32
41
  * Whether a link target is a root-relative internal path (`/x`) — the only
33
42
  * shape a base path applies to. Protocol-relative (`//host`), absolute URLs,
@@ -328,13 +328,6 @@ const finalize = (
328
328
  );
329
329
  }
330
330
 
331
- if (client && !source) {
332
- warnings.push(
333
- `Override "${key}" declares client: "${client}" but its component couldn't be resolved to a file, so it can't hydrate. Reference it by an imported component or a path string.`
334
- );
335
- return { identifier, key, source: null };
336
- }
337
-
338
331
  if (!client && source?.framework) {
339
332
  warnings.push(
340
333
  `Override "${key}" points to a ${FRAMEWORK_LABEL[source.framework]} component (${label}) but has no hydration mode, so it renders as static HTML with no interactivity. Add one, e.g. \`${key}: { component: ${JSON.stringify(label)}, client: "load" }\`.`
@@ -269,20 +269,88 @@ export interface ContentConfig {
269
269
  * Releases, Sanity, Notion, or a custom `ContentSource`.
270
270
  */
271
271
  sources?: ContentSourceInput[];
272
+ /**
273
+ * Per-type content definitions, keyed by the frontmatter `type` they apply
274
+ * to (including `defaultType`, for pages that set none):
275
+ *
276
+ * ```ts
277
+ * import { z } from "zod";
278
+ *
279
+ * content: {
280
+ * types: {
281
+ * rfc: {
282
+ * frontmatter: {
283
+ * domain: z.string(),
284
+ * status: z.enum(["draft", "enforced"]),
285
+ * },
286
+ * },
287
+ * },
288
+ * },
289
+ * ```
290
+ */
291
+ types?: Record<string, ContentTypeConfig>;
292
+ }
293
+
294
+ /**
295
+ * A per-type content definition: configuration that applies only to pages
296
+ * whose resolved frontmatter `type` matches the map key.
297
+ */
298
+ export interface ContentTypeConfig {
299
+ /**
300
+ * Custom frontmatter keys whose values become filterable facets for pages
301
+ * of this type. Faceted values ride along on search documents
302
+ * (`blume-search.json` and the MCP index), and the MCP `search_docs` and
303
+ * `list_pages` tools accept a `filters` input matching against them:
304
+ *
305
+ * ```ts
306
+ * content: {
307
+ * types: {
308
+ * rfc: {
309
+ * facets: ["domain", "status"],
310
+ * frontmatter: { domain: z.string(), status: z.string() },
311
+ * },
312
+ * },
313
+ * },
314
+ * ```
315
+ *
316
+ * Each name must be a custom key declared for the type — in its
317
+ * `frontmatter` map or the site-wide `frontmatter.extend`. String values
318
+ * facet as-is; numbers and booleans are stringified; anything else
319
+ * (objects, arrays, transformed dates) does not facet.
320
+ */
321
+ facets?: string[];
322
+ /**
323
+ * Custom frontmatter keys for pages of this type, layered on top of the
324
+ * site-wide `frontmatter.extend` (a key can be declared in one or the
325
+ * other, not both). Schemas follow the same rules as `extend`: any
326
+ * Standard Schema library works, every declared key is validated on every
327
+ * page of the type — absent ones included — so a required schema enforces
328
+ * the key type-wide (mark it `.optional()` to validate only when present),
329
+ * and validated values land on the page record's `custom` field. Built-in
330
+ * frontmatter fields cannot be redeclared.
331
+ */
332
+ frontmatter?: Record<string, StandardSchema>;
272
333
  }
273
334
 
274
335
  // ---------------------------------------------------------------------------
275
336
  // Navigation
276
337
  // ---------------------------------------------------------------------------
277
338
 
339
+ /**
340
+ * A header label, optionally per locale: a plain string, or a map of locale
341
+ * code to label (`{ en: "Docs", ja: "ドキュメント" }`). The active locale's
342
+ * entry wins, then the default locale's, then the map's first entry.
343
+ */
344
+ export type LocalizableLabel = string | Record<string, string>;
345
+
278
346
  /** A single item inside a header tab's dropdown. */
279
347
  export interface NavTabItem {
280
348
  /** Secondary line under the label. */
281
349
  description?: string;
282
350
  /** Lucide icon name shown beside the label. */
283
351
  icon?: string;
284
- /** Item label. */
285
- label: string;
352
+ /** Item label, optionally per locale. */
353
+ label: LocalizableLabel;
286
354
  /** Route the item links to. */
287
355
  path: string;
288
356
  /** Short tag/pill (e.g. `New`, `Beta`). */
@@ -304,8 +372,8 @@ export interface NavTab {
304
372
  icon?: string;
305
373
  /** Dropdown items; omit for a plain link tab. */
306
374
  items?: NavTabItem[];
307
- /** Tab label. */
308
- label: string;
375
+ /** Tab label, optionally per locale. */
376
+ label: LocalizableLabel;
309
377
  /** Route the tab links to. */
310
378
  path: string;
311
379
  }
@@ -512,7 +580,10 @@ export interface MixedbreadSearch {
512
580
  export interface SearchPopularLink {
513
581
  /** Internal route or external URL. */
514
582
  href: string;
515
- /** Built-in icon name shown beside the label; defaults to the file glyph. */
583
+ /**
584
+ * Icon shown beside the label — a built-in name, image path/URL, or inline
585
+ * SVG (same as nav icons). Defaults to the file glyph.
586
+ */
516
587
  icon?: string;
517
588
  /** Link label shown in the dialog. */
518
589
  label: string;
@@ -728,6 +799,12 @@ export interface LocaleConfigInput {
728
799
  dir?: "ltr" | "rtl";
729
800
  /** Human-readable name shown in the switcher. */
730
801
  label: string;
802
+ /**
803
+ * Freeform style guidance for `blume translate`, e.g. "Brazilian
804
+ * Portuguese, informal você". Pins register and dialect from the first
805
+ * translation and wins over an existing translation's style on reruns.
806
+ */
807
+ style?: string;
731
808
  }
732
809
 
733
810
  /**
@@ -1145,7 +1222,8 @@ export interface FrontmatterConfig {
1145
1222
  * so a required schema enforces the key site-wide; mark it `.optional()`
1146
1223
  * to validate only when present. Validated values are preserved on each
1147
1224
  * page record's `custom` field. Built-in frontmatter fields cannot be
1148
- * redeclared.
1225
+ * redeclared. To scope a key to one content type instead, declare it under
1226
+ * `content.types.<type>.frontmatter`.
1149
1227
  */
1150
1228
  extend?: Record<string, StandardSchema>;
1151
1229
  }
@@ -219,14 +219,14 @@ export const loadConfig = async (
219
219
  // Surface every issue in one failing run — reporting only the first turns
220
220
  // a three-mistake config into three fix-rerun-fail loops.
221
221
  const moreIssues = rest.map((d) => ` - ${d.message}`).join("\n");
222
- throw new BlumeError(
222
+ const detail =
223
223
  rest.length > 0
224
224
  ? {
225
225
  ...primary,
226
226
  message: `${primary.message}\n${rest.length} more config issue(s):\n${moreIssues}`,
227
227
  }
228
- : primary
229
- );
228
+ : primary;
229
+ throw new BlumeError(detail);
230
230
  }
231
231
 
232
232
  // Resolve the canonical site URL, then SEO defaults that depend on it.
@@ -1,3 +1,4 @@
1
+ import { colors } from "consola/utils";
1
2
  import { relative } from "pathe";
2
3
  import type { ZodError } from "zod";
3
4
 
@@ -196,25 +197,14 @@ export const diagnosticsFromZod = (
196
197
  options
197
198
  );
198
199
 
199
- const ESC = String.fromCodePoint(27);
200
- const COLORS = {
201
- blue: `${ESC}[34m`,
202
- bold: `${ESC}[1m`,
203
- cyan: `${ESC}[36m`,
204
- dim: `${ESC}[2m`,
205
- red: `${ESC}[31m`,
206
- reset: `${ESC}[0m`,
207
- yellow: `${ESC}[33m`,
208
- };
209
-
210
- const severityColor = (severity: Diagnostic["severity"]): string => {
200
+ const severityColor = (severity: Diagnostic["severity"]) => {
211
201
  if (severity === "error") {
212
- return COLORS.red;
202
+ return colors.red;
213
203
  }
214
204
  if (severity === "warning") {
215
- return COLORS.yellow;
205
+ return colors.yellow;
216
206
  }
217
- return COLORS.blue;
207
+ return colors.blue;
218
208
  };
219
209
 
220
210
  /** Format a single diagnostic for terminal output. */
@@ -224,14 +214,14 @@ export const formatDiagnostic = (
224
214
  ): string => {
225
215
  const color = severityColor(diagnostic.severity);
226
216
  const lines: string[] = [
227
- `${color}${COLORS.bold}${diagnostic.code}${COLORS.reset} ${diagnostic.message}`,
217
+ `${color(colors.bold(diagnostic.code))} ${diagnostic.message}`,
228
218
  ];
229
219
 
230
220
  // An audit finding is about a built URL, and names the source file that fixes
231
221
  // it as a second line ("at /docs/api" / "in docs/api.mdx:3:2"). Everything
232
222
  // else is about a file alone, and keeps the original single `at file` line.
233
223
  if (diagnostic.url) {
234
- lines.push(` ${COLORS.dim}at ${diagnostic.url}${COLORS.reset}`);
224
+ lines.push(` ${colors.dim(`at ${diagnostic.url}`)}`);
235
225
  }
236
226
  if (diagnostic.file) {
237
227
  const location = root ? relative(root, diagnostic.file) : diagnostic.file;
@@ -240,15 +230,15 @@ export const formatDiagnostic = (
240
230
  const position =
241
231
  diagnostic.line === undefined ? "" : `:${diagnostic.line}${column}`;
242
232
  const label = diagnostic.url ? "in" : "at";
243
- lines.push(` ${COLORS.dim}${label} ${location}${position}${COLORS.reset}`);
233
+ lines.push(` ${colors.dim(`${label} ${location}${position}`)}`);
244
234
  }
245
235
 
246
236
  if (diagnostic.suggestion) {
247
- lines.push(` ${COLORS.cyan}fix: ${diagnostic.suggestion}${COLORS.reset}`);
237
+ lines.push(` ${colors.cyan(`fix: ${diagnostic.suggestion}`)}`);
248
238
  }
249
239
 
250
240
  if (diagnostic.docsUrl) {
251
- lines.push(` ${COLORS.dim}docs: ${diagnostic.docsUrl}${COLORS.reset}`);
241
+ lines.push(` ${colors.dim(`docs: ${diagnostic.docsUrl}`)}`);
252
242
  }
253
243
 
254
244
  return lines.join("\n");
@@ -0,0 +1,22 @@
1
+ import { mkdir } from "node:fs/promises";
2
+
3
+ import { dirname } from "pathe";
4
+ import writeFileAtomic from "write-file-atomic";
5
+
6
+ /**
7
+ * Write text to `path` atomically (unique temp file + rename) after ensuring
8
+ * the parent directory exists, so a concurrent reader or file watcher never
9
+ * observes a missing or half-written file. write-file-atomic's temp names are
10
+ * unique per call — a pid-suffixed temp name is not, and two concurrent
11
+ * writers to the same target in one process (translate lanes, staged-content
12
+ * writes) would interleave through a shared temp file. `fsync` is off to
13
+ * match the previous behavior: the point is watcher atomicity, not crash
14
+ * durability, and a per-file fsync would slow dev regeneration.
15
+ */
16
+ export const writeTextAtomic = async (
17
+ path: string,
18
+ text: string
19
+ ): Promise<void> => {
20
+ await mkdir(dirname(path), { recursive: true });
21
+ await writeFileAtomic(path, text, { encoding: "utf-8", fsync: false });
22
+ };
package/src/core/graph.ts CHANGED
@@ -4,6 +4,7 @@ import { validateNavIcons, validateNavStructure } from "./nav-diagnostics.ts";
4
4
  import { buildNavigation } from "./navigation.ts";
5
5
  import type {
6
6
  FolderMeta,
7
+ LocalizableLabel,
7
8
  ResolvedConfig,
8
9
  ResolvedI18nConfig,
9
10
  } from "./schema.ts";
@@ -11,6 +12,7 @@ import type {
11
12
  ContentGraph,
12
13
  Diagnostic,
13
14
  Navigation,
15
+ NavTab,
14
16
  PageRecord,
15
17
  } from "./types.ts";
16
18
 
@@ -79,6 +81,42 @@ const localePagesFor = (
79
81
  return [...real, ...filled];
80
82
  };
81
83
 
84
+ /**
85
+ * Resolve a possibly-per-locale header label to the string a locale renders:
86
+ * the active locale's entry, else the default locale's, else the map's first
87
+ * entry (which is also what a single-locale site gets).
88
+ */
89
+ const resolveLabel = (
90
+ label: LocalizableLabel,
91
+ locale: string,
92
+ defaultLocale?: string
93
+ ): string => {
94
+ if (typeof label === "string") {
95
+ return label;
96
+ }
97
+ return (
98
+ label[locale] ??
99
+ (defaultLocale === undefined ? undefined : label[defaultLocale]) ??
100
+ Object.values(label)[0] ??
101
+ ""
102
+ );
103
+ };
104
+
105
+ /** Resolve every localizable label in the configured tabs for one locale. */
106
+ const resolveTabLabels = (
107
+ tabs: BuildContentGraphOptions["navigation"]["tabs"],
108
+ locale: string,
109
+ defaultLocale?: string
110
+ ): NavTab[] =>
111
+ (tabs ?? []).map((tab) => ({
112
+ ...tab,
113
+ items: tab.items?.map((item) => ({
114
+ ...item,
115
+ label: resolveLabel(item.label, locale, defaultLocale),
116
+ })),
117
+ label: resolveLabel(tab.label, locale, defaultLocale),
118
+ }));
119
+
82
120
  /** Build one locale's navigation tree from its own pages and folder meta. */
83
121
  const buildLocaleNavigation = (
84
122
  code: string,
@@ -95,7 +133,11 @@ const buildLocaleNavigation = (
95
133
  // selector's items intentionally target specific locales.
96
134
  const localizePath = (path: string): string =>
97
135
  path.startsWith("/") ? localizeRoute(path, code, i18n) : path;
98
- const tabs = options.navigation.tabs?.map((tab) => ({
136
+ const tabs = resolveTabLabels(
137
+ options.navigation.tabs,
138
+ code,
139
+ i18n.defaultLocale
140
+ ).map((tab) => ({
99
141
  ...tab,
100
142
  ...(tab.href ? { href: localizePath(tab.href) } : {}),
101
143
  items: tab.items?.map((item) => ({
@@ -220,7 +262,9 @@ export const buildContentGraph = (
220
262
  selectors: options.navigation.selectors,
221
263
  sharedFolderMeta: options.sharedFolderMeta,
222
264
  sidebar: options.navigation.sidebar.items,
223
- tabs: options.navigation.tabs,
265
+ // No locale to prefer: a per-locale label map resolves to its first
266
+ // entry on a single-locale site.
267
+ tabs: resolveTabLabels(options.navigation.tabs, ""),
224
268
  }),
225
269
  navigationByLocale: {} as Record<string, Navigation>,
226
270
  };
@@ -28,6 +28,8 @@ const uiStringsObject = z.object({
28
28
  exportEpub: z.string().default("Export to EPUB"),
29
29
  exportPdf: z.string().default("Export to PDF"),
30
30
  generating: z.string().default("Generating…"),
31
+ // `{name}` is replaced with the provider's brand name at render time.
32
+ openIn: z.string().default("Open in {name}"),
31
33
  openInChat: z.string().default("Open in chat"),
32
34
  scrollToTop: z.string().default("Scroll to top"),
33
35
  })
package/src/core/i18n.ts CHANGED
@@ -148,6 +148,37 @@ export const localePlacement = (
148
148
  return { locales: [locale], navPath: rest.join("/") };
149
149
  };
150
150
 
151
+ /**
152
+ * The inverse of {@link localePlacement}: where a default-locale file's
153
+ * translation into `locale` lives, by parser:
154
+ * - `dir`: a leading locale directory (`guides/x.mdx` → `fr/guides/x.mdx`)
155
+ * - `dot`: a filename suffix (`guides/x.mdx` → `guides/x.fr.mdx`)
156
+ *
157
+ * Under `dot`, a source authored with an explicit default-locale suffix
158
+ * (`x.en.mdx`) swaps it for the target's (`x.fr.mdx`) — same case-insensitive
159
+ * last-dot-inside-filename logic as `localePlacement`, so both spellings of a
160
+ * default-locale file resolve to one canonical target.
161
+ */
162
+ export const localeTargetPath = (
163
+ rel: string,
164
+ ext: string,
165
+ locale: string,
166
+ i18n: ResolvedI18nConfig
167
+ ): string => {
168
+ if (i18n.parser === "dot") {
169
+ let base = rel.slice(0, rel.length - ext.length);
170
+ const lastDot = base.lastIndexOf(".");
171
+ if (lastDot > base.lastIndexOf("/")) {
172
+ const suffix = base.slice(lastDot + 1).toLowerCase();
173
+ if (i18n.locales.some((entry) => entry.code.toLowerCase() === suffix)) {
174
+ base = base.slice(0, lastDot);
175
+ }
176
+ }
177
+ return `${base}.${locale}${ext}`;
178
+ }
179
+ return `${locale}/${rel}`;
180
+ };
181
+
151
182
  /**
152
183
  * Warn about top-level content folders that look like a locale (a code Blume
153
184
  * recognizes) but aren't declared in `i18n.locales`. Without this they're
@@ -1,3 +1,4 @@
1
+ import { isAssetIcon } from "../theme/icon-kind.ts";
1
2
  import { hasIcon } from "../theme/icons.ts";
2
3
  import type { Diagnostic, NavNode, Navigation, PageRecord } from "./types.ts";
3
4
 
@@ -8,12 +9,8 @@ import type { Diagnostic, NavNode, Navigation, PageRecord } from "./types.ts";
8
9
  * covers every source (config, folder meta, frontmatter) at once.
9
10
  */
10
11
 
11
- const IMAGE_ICON =
12
- /^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)|\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
13
-
14
- /** Whether an icon string is an asset (image/URL/inline SVG), not a set name. */
15
- const isAssetIcon = (value: string): boolean =>
16
- value.startsWith("<") || IMAGE_ICON.test(value);
12
+ const ICON_SHAPE_HINT =
13
+ "Use a built-in icon name, an image path/URL, or inline SVG markup.";
17
14
 
18
15
  /** Flatten a sidebar tree to every node, descending into groups. */
19
16
  const flattenNodes = (nodes: NavNode[]): NavNode[] =>
@@ -66,9 +63,14 @@ const unknownIconDiagnostics = (
66
63
  continue;
67
64
  }
68
65
  seen.add(icon);
66
+ // Markup that isn't a complete <svg> element (an <img> tag, a truncated
67
+ // svg) is a shape problem, not a set-name typo — say so.
68
+ const message = icon.trimStart().startsWith("<")
69
+ ? `Icon markup "${icon}" (${where}) isn't a complete inline <svg> element, so it won't render.`
70
+ : `Unknown icon "${icon}" (${where}) — it isn't in Blume's icon set.`;
69
71
  diagnostics.push({
70
72
  code: "BLUME_UNKNOWN_ICON",
71
- message: `Unknown icon "${icon}" (${where}) — it isn't in Blume's icon set.`,
73
+ message,
72
74
  severity: "warning",
73
75
  suggestion,
74
76
  });
@@ -78,16 +80,11 @@ const unknownIconDiagnostics = (
78
80
 
79
81
  /** Warn about icon names that aren't in Blume's set (skipping image/SVG icons). */
80
82
  export const validateNavIcons = (navigation: Navigation): Diagnostic[] =>
81
- unknownIconDiagnostics(
82
- collectIcons(navigation),
83
- "Use a built-in icon name, an image path/URL, or inline SVG markup."
84
- );
83
+ unknownIconDiagnostics(collectIcons(navigation), ICON_SHAPE_HINT);
85
84
 
86
85
  /**
87
- * Warn about unknown icons on curated `search.popular` links. Separate from
88
- * {@link validateNavIcons} because these live under `search`, not the built
89
- * navigation — and unlike nav icons they resolve in a *client* island, so only
90
- * set names work (an image/SVG icon quietly falls back to the file glyph).
86
+ * Warn about unknown icons on curated `search.popular` links. Same accepted
87
+ * input shapes as nav icons — resolved to markup on the server for the island.
91
88
  */
92
89
  export const validateSearchPopularIcons = (
93
90
  popular: { icon?: string; label: string }[]
@@ -97,25 +94,7 @@ export const validateSearchPopularIcons = (
97
94
  ? [{ icon: link.icon, where: `popular link "${link.label}"` }]
98
95
  : []
99
96
  );
100
- // Asset icons are valid in the nav, so the shared helper skips them — but
101
- // here they are exactly the silent failure this validator exists to catch.
102
- const diagnostics: Diagnostic[] = [];
103
- const seen = new Set<string>();
104
- for (const { icon, where } of icons) {
105
- if (isAssetIcon(icon) && !seen.has(icon)) {
106
- seen.add(icon);
107
- diagnostics.push({
108
- code: "BLUME_UNKNOWN_ICON",
109
- message: `Icon "${icon}" (${where}) is an image or inline SVG — popular links render in the client search island, where only built-in icon names resolve, so it falls back to the file glyph.`,
110
- severity: "warning",
111
- suggestion: "Use a built-in icon name.",
112
- });
113
- }
114
- }
115
- return [
116
- ...diagnostics,
117
- ...unknownIconDiagnostics(icons, "Use a built-in icon name."),
118
- ];
97
+ return unknownIconDiagnostics(icons, ICON_SHAPE_HINT);
119
98
  };
120
99
 
121
100
  /** Whether an internal path resolves to a page or a section that has pages. */
@@ -143,12 +143,22 @@ const normalizeLoadedEntries = (
143
143
  loaded: ({ source: ContentSource } & SourceLoadResult)[],
144
144
  config: ResolvedConfig
145
145
  ): { pages: PageRecord[]; diagnostics: Diagnostic[]; droppedPages: number } => {
146
- // Only thread `frontmatter.extend` through when a project opts in, so the
147
- // known-key split in `normalizeEntry` stays off the default path.
146
+ // Only thread `frontmatter.extend` / `content.types` through when a project
147
+ // opts in, so the known-key split in `normalizeEntry` stays off the default
148
+ // path.
148
149
  const frontmatterExtend =
149
150
  Object.keys(config.frontmatter.extend).length > 0
150
151
  ? config.frontmatter.extend
151
152
  : undefined;
153
+ const declaredTypes = Object.entries(config.content.types).filter(
154
+ ([, type]) => Object.keys(type.frontmatter).length > 0
155
+ );
156
+ const typeFrontmatter =
157
+ declaredTypes.length > 0
158
+ ? Object.fromEntries(
159
+ declaredTypes.map(([name, type]) => [name, type.frontmatter])
160
+ )
161
+ : undefined;
152
162
 
153
163
  const pages: PageRecord[] = [];
154
164
  const allDiagnostics: Diagnostic[] = [];
@@ -166,6 +176,7 @@ const normalizeLoadedEntries = (
166
176
  prefix: source.prefix,
167
177
  staged: source.staged,
168
178
  },
179
+ typeFrontmatter,
169
180
  });
170
181
  if (normalized.pages.length === 0 && normalized.diagnostics.length > 0) {
171
182
  droppedPages += 1;