blume 0.8.0 → 1.0.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 (80) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/cli/index.js +373 -28
  3. package/dist/cli/index.js.map +51 -50
  4. package/dist/types/core/config-input.d.ts +11 -0
  5. package/dist/types/core/data.d.ts +5 -0
  6. package/dist/types/core/i18n-ui.d.ts +50 -0
  7. package/dist/types/core/schema.d.ts +77 -1
  8. package/dist/types/seo/x-handle.d.ts +12 -0
  9. package/docs/advanced/api-reference.mdx +1 -1
  10. package/docs/advanced/changelog.mdx +1 -1
  11. package/docs/configuration/export.mdx +1 -1
  12. package/docs/configuration/seo.mdx +33 -3
  13. package/docs/content/components.mdx +1 -1
  14. package/docs/content/syntax.mdx +2 -2
  15. package/docs/index.mdx +1 -1
  16. package/docs/reference/cli.mdx +1 -1
  17. package/docs/reference/frontmatter.mdx +1 -1
  18. package/package.json +9 -1
  19. package/src/ai/mcp/tools.ts +1 -1
  20. package/src/astro/generate.ts +1 -0
  21. package/src/astro/templates.ts +51 -7
  22. package/src/cli/commands/build.ts +25 -8
  23. package/src/cli/internal-error.ts +9 -4
  24. package/src/cli/prepare.ts +3 -2
  25. package/src/components/Icon.astro +2 -1
  26. package/src/components/islands/ask-ai.tsx +30 -5
  27. package/src/components/layout/Breadcrumbs.astro +11 -2
  28. package/src/components/layout/NavTree.astro +2 -2
  29. package/src/components/layout/PageLayout.astro +40 -1
  30. package/src/components/layout/Pagination.astro +10 -4
  31. package/src/components/layout/ReferenceLayout.astro +12 -1
  32. package/src/components/layout/RootLayout.astro +75 -3
  33. package/src/components/layout/Search.astro +23 -5
  34. package/src/core/config-input.ts +11 -0
  35. package/src/core/data.ts +2 -0
  36. package/src/core/frontmatter.ts +7 -0
  37. package/src/core/i18n-ui.ts +9 -0
  38. package/src/core/i18n.ts +16 -8
  39. package/src/core/schema.ts +35 -0
  40. package/src/core/ui-packs/ar.ts +7 -0
  41. package/src/core/ui-packs/bg.ts +7 -0
  42. package/src/core/ui-packs/bn.ts +7 -0
  43. package/src/core/ui-packs/ca.ts +7 -0
  44. package/src/core/ui-packs/cs.ts +7 -0
  45. package/src/core/ui-packs/da.ts +7 -0
  46. package/src/core/ui-packs/de.ts +7 -0
  47. package/src/core/ui-packs/el.ts +7 -0
  48. package/src/core/ui-packs/es.ts +7 -0
  49. package/src/core/ui-packs/fa.ts +7 -0
  50. package/src/core/ui-packs/fi.ts +7 -0
  51. package/src/core/ui-packs/fr.ts +7 -0
  52. package/src/core/ui-packs/he.ts +7 -0
  53. package/src/core/ui-packs/hi.ts +7 -0
  54. package/src/core/ui-packs/hr.ts +7 -0
  55. package/src/core/ui-packs/hu.ts +7 -0
  56. package/src/core/ui-packs/id.ts +7 -0
  57. package/src/core/ui-packs/it.ts +7 -0
  58. package/src/core/ui-packs/ja.ts +7 -0
  59. package/src/core/ui-packs/ko.ts +7 -0
  60. package/src/core/ui-packs/nl.ts +7 -0
  61. package/src/core/ui-packs/no.ts +7 -0
  62. package/src/core/ui-packs/pl.ts +7 -0
  63. package/src/core/ui-packs/pt-br.ts +7 -0
  64. package/src/core/ui-packs/pt.ts +7 -0
  65. package/src/core/ui-packs/ro.ts +7 -0
  66. package/src/core/ui-packs/ru.ts +7 -0
  67. package/src/core/ui-packs/sk.ts +7 -0
  68. package/src/core/ui-packs/sr.ts +7 -0
  69. package/src/core/ui-packs/sv.ts +7 -0
  70. package/src/core/ui-packs/th.ts +7 -0
  71. package/src/core/ui-packs/tr.ts +7 -0
  72. package/src/core/ui-packs/uk.ts +7 -0
  73. package/src/core/ui-packs/vi.ts +7 -0
  74. package/src/core/ui-packs/zh-tw.ts +7 -0
  75. package/src/core/ui-packs/zh.ts +7 -0
  76. package/src/og/card.ts +4 -2
  77. package/src/og/dimensions.ts +12 -0
  78. package/src/openapi/render-mdx.ts +62 -1
  79. package/src/seo/jsonld.ts +7 -1
  80. package/src/seo/x-handle.ts +18 -0
@@ -9,7 +9,13 @@ import type {
9
9
  } from "../../core/types.ts";
10
10
  import "blume:theme";
11
11
  import type { ComponentOverride } from "../../core/define-components.ts";
12
- import { buildStructuredData } from "../../seo/jsonld.ts";
12
+ import {
13
+ OG_IMAGE_HEIGHT,
14
+ OG_IMAGE_TYPE,
15
+ OG_IMAGE_WIDTH,
16
+ } from "../../og/dimensions.ts";
17
+ import { buildStructuredData, toIso } from "../../seo/jsonld.ts";
18
+ import { normalizeXHandle } from "../../seo/x-handle.ts";
13
19
  import { withBase } from "../islands/base-path.ts";
14
20
  import Analytics from "./Analytics.astro";
15
21
  import Banner from "./Banner.astro";
@@ -79,6 +85,19 @@ interface Props {
79
85
  searchEnabled: boolean;
80
86
  indexable: boolean;
81
87
  ogImage?: string | null;
88
+ /**
89
+ * Whether `ogImage` is Blume's generated card (rather than a user `seo.image`).
90
+ * Only the generated card has known dimensions and format, so `og:image:width`
91
+ * / `og:image:height` / `og:image:type` are declared for it alone — asserting
92
+ * 1200x630 PNG over someone's arbitrary JPEG would be a lie crawlers act on.
93
+ */
94
+ ogGenerated?: boolean;
95
+ /**
96
+ * X (Twitter) attribution: `handle` is the site's account (`twitter:site`),
97
+ * `creator` the author's (`twitter:creator`). The rest of the X card is read
98
+ * from `og:*`, so these are the only values X can't infer.
99
+ */
100
+ x?: { creator?: string; handle?: string };
82
101
  canonical?: string | null;
83
102
  editUrl?: string | null;
84
103
  askEnabled?: boolean;
@@ -154,6 +173,8 @@ const {
154
173
  searchEnabled,
155
174
  indexable,
156
175
  ogImage,
176
+ ogGenerated = false,
177
+ x,
157
178
  canonical,
158
179
  editUrl,
159
180
  askEnabled,
@@ -244,6 +265,24 @@ if (isBare) {
244
265
  }
245
266
  const pageTitle = page.title ? `${page.title} - ${site.title}` : site.title;
246
267
  const description = page.description ?? site.description;
268
+ // Blog posts and changelog entries are dated writing, so they take `og:type:
269
+ // article` — the same split JSON-LD makes when it picks an article @type. Docs
270
+ // pages (and the changelog index, which has no `type`) stay `website`.
271
+ const ogType =
272
+ pageType === "blog" || pageType === "changelog" ? "article" : "website";
273
+ // `article:*` timestamps only belong on an article; a docs page carrying them
274
+ // would be declaring properties its `og:type` doesn't define.
275
+ const articlePublished = ogType === "article" ? toIso(published) : undefined;
276
+ const articleModified = ogType === "article" ? toIso(lastModified) : undefined;
277
+ // X sizes the card from `twitter:card`: a large image needs the wide variant,
278
+ // and a page with no image still gets a card (the compact `summary`) rather
279
+ // than rendering as a bare link.
280
+ const twitterCard = ogImage ? "summary_large_image" : "summary";
281
+ // Normalized here, not just in the config schema: a page's `seo.x.creator`
282
+ // arrives as raw frontmatter (the collections carry no schema), so the schema's
283
+ // transform never touches it — `creator: guestauthor` still needs its `@`.
284
+ const xSite = normalizeXHandle(x?.handle);
285
+ const xCreator = normalizeXHandle(x?.creator);
247
286
 
248
287
  // "Last updated on <date>" — formatted in UTC to match the changelog timeline.
249
288
  const lastModifiedDate = lastModified ? new Date(lastModified) : null;
@@ -313,17 +352,46 @@ const bannerScript = banner?.dismissible
313
352
  {xDefault && <link href={xDefault} hreflang="x-default" rel="alternate" />}
314
353
  {noindex && <meta name="robots" content="noindex" />}
315
354
  {description && <meta name="description" content={description} />}
355
+ <meta property="og:type" content={ogType} />
356
+ <meta property="og:site_name" content={site.title} />
316
357
  <meta property="og:title" content={pageTitle} />
317
358
  {description && <meta property="og:description" content={description} />}
359
+ {canonical && <meta property="og:url" content={canonical} />}
360
+ {
361
+ articlePublished && (
362
+ <meta property="article:published_time" content={articlePublished} />
363
+ )
364
+ }
365
+ {
366
+ articleModified && (
367
+ <meta property="article:modified_time" content={articleModified} />
368
+ )
369
+ }
318
370
  {
319
371
  ogImage && (
320
372
  <>
321
373
  <meta property="og:image" content={ogImage} />
374
+ {ogGenerated && (
375
+ <>
376
+ <meta property="og:image:type" content={OG_IMAGE_TYPE} />
377
+ <meta property="og:image:width" content={String(OG_IMAGE_WIDTH)} />
378
+ <meta
379
+ property="og:image:height"
380
+ content={String(OG_IMAGE_HEIGHT)}
381
+ />
382
+ <meta property="og:image:alt" content={pageTitle} />
383
+ </>
384
+ )}
322
385
  <meta name="twitter:image" content={ogImage} />
323
- <meta name="twitter:card" content="summary_large_image" />
386
+ {ogGenerated && <meta name="twitter:image:alt" content={pageTitle} />}
324
387
  </>
325
388
  )
326
389
  }
390
+ <meta name="twitter:card" content={twitterCard} />
391
+ <meta name="twitter:title" content={pageTitle} />
392
+ {description && <meta name="twitter:description" content={description} />}
393
+ {xSite && <meta name="twitter:site" content={xSite} />}
394
+ {xCreator && <meta name="twitter:creator" content={xCreator} />}
327
395
  {
328
396
  feeds?.map((feed) => (
329
397
  <link
@@ -501,7 +569,11 @@ const bannerScript = banner?.dismissible
501
569
  </nav>
502
570
  </aside>
503
571
  <main class="px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
504
- <BreadcrumbsSlot crumbs={crumbs} wide={isApiOperation} />
572
+ <BreadcrumbsSlot
573
+ crumbs={crumbs}
574
+ strings={navStrings}
575
+ wide={isApiOperation}
576
+ />
505
577
  <TableOfContentsSlot
506
578
  headings={tocHeadings}
507
579
  title={strings.toc.title}
@@ -33,6 +33,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
33
33
  <blume-search
34
34
  class="contents"
35
35
  data-ask={askEnabled ? "" : undefined}
36
+ data-i18n-all={s.all}
36
37
  data-i18n-ask={s.askAi}
37
38
  data-i18n-ask-hint={s.askAiHint}
38
39
  data-i18n-dev={s.devOnly}
@@ -211,6 +212,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
211
212
  errorMsg = "Something went wrong. Please try again.";
212
213
  askMsg = "Ask AI";
213
214
  askHintMsg = "Get an instant answer from AI";
215
+ allMsg = "All";
214
216
  popularMsg = "Popular";
215
217
  resultsMsg = "Results";
216
218
  // The active locale to filter to (null when i18n is off), and whether the
@@ -227,6 +229,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
227
229
  this.askMsg = this.getAttribute("data-i18n-ask") || this.askMsg;
228
230
  this.askHintMsg =
229
231
  this.getAttribute("data-i18n-ask-hint") || this.askHintMsg;
232
+ this.allMsg = this.getAttribute("data-i18n-all") || this.allMsg;
230
233
  this.popularMsg =
231
234
  this.getAttribute("data-i18n-popular") || this.popularMsg;
232
235
  this.resultsMsg =
@@ -293,12 +296,22 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
293
296
  );
294
297
 
295
298
  document.addEventListener("keydown", (event) => {
296
- const inField = this.isField(event.target);
297
299
  if (
298
- ((event.key === "k" || event.key === "K") &&
299
- (event.metaKey || event.ctrlKey)) ||
300
- (event.key === "/" && !inField)
300
+ (event.key === "k" || event.key === "K") &&
301
+ (event.metaKey || event.ctrlKey)
301
302
  ) {
303
+ // ⌘K toggles, mirroring the Ask AI panel's ⌘I: pressing it with
304
+ // the dialog open must close it, not re-showModal an open dialog
305
+ // (an InvalidStateError on older engines).
306
+ event.preventDefault();
307
+ if (this.dialog.open) {
308
+ this.dialog.close();
309
+ } else {
310
+ this.open();
311
+ }
312
+ } else if (event.key === "/" && !this.isField(event.target)) {
313
+ // "/" stays open-only; the field guard keeps it inert while
314
+ // typing (including in the search input itself).
302
315
  event.preventDefault();
303
316
  this.open();
304
317
  }
@@ -326,6 +339,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
326
339
  }
327
340
 
328
341
  async open() {
342
+ // Re-entrant opens (e.g. the open button while already shown) must
343
+ // not call showModal on an open dialog.
344
+ if (this.dialog.open) {
345
+ return;
346
+ }
329
347
  this.dialog.showModal();
330
348
  this.input.focus();
331
349
  this.input.select();
@@ -499,7 +517,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
499
517
  this.filters.hidden = false;
500
518
  this.filters.replaceChildren();
501
519
  const total = sections.reduce((sum, s) => sum + s.count, 0);
502
- this.filters.appendChild(this.createPill("All", total, null));
520
+ this.filters.appendChild(this.createPill(this.allMsg, total, null));
503
521
  for (const section of sections) {
504
522
  this.filters.appendChild(
505
523
  this.createPill(section.label, section.count, section.label)
@@ -736,6 +736,17 @@ export interface SeoConfig {
736
736
  sitemap?: boolean;
737
737
  /** Emit schema.org JSON-LD in each page's `<head>`. Defaults to `true`. */
738
738
  structuredData?: boolean;
739
+ /**
740
+ * X (Twitter) attribution for share cards. Handles may omit the `@`. The rest
741
+ * of the X card is read from the `og:*` tags, so these accounts are the only
742
+ * values X cannot infer.
743
+ */
744
+ x?: {
745
+ /** Author account (`twitter:creator`); a page can override it via `seo.x.creator` frontmatter. */
746
+ creator?: string;
747
+ /** The site's own account (`twitter:site`), e.g. `@blume`. */
748
+ handle?: string;
749
+ };
739
750
  }
740
751
 
741
752
  // ---------------------------------------------------------------------------
package/src/core/data.ts CHANGED
@@ -120,6 +120,8 @@ export interface BlumeDataConfig {
120
120
  title: string;
121
121
  /** Table-of-contents settings: whether to show it and the heading range. */
122
122
  toc: ResolvedConfig["toc"];
123
+ /** X (Twitter) attribution: the site's account, and a default creator. */
124
+ x: { creator?: string; handle?: string };
123
125
  }
124
126
 
125
127
  /**
@@ -11,6 +11,7 @@ import { dump, load } from "js-yaml";
11
11
 
12
12
  type MatterInput = Parameters<typeof baseMatter>[0];
13
13
  type MatterOptions = Parameters<typeof baseMatter>[1];
14
+ type ReadArgs = Parameters<typeof baseMatter.read>;
14
15
  type StringifyArgs = Parameters<typeof baseMatter.stringify>;
15
16
 
16
17
  const yamlEngine = {
@@ -27,11 +28,17 @@ const withYamlEngine = <O>(options: O): O =>
27
28
  },
28
29
  }) as O;
29
30
 
31
+ // Every helper that parses or emits YAML (`read`, `stringify`) must be
32
+ // re-wrapped here — Object.assign copies gray-matter's own helpers, which use
33
+ // its default `safeLoad` engine and would reintroduce the crash. `test` only
34
+ // checks for a delimiter, so the copied original is safe.
30
35
  const matter = Object.assign(
31
36
  (input: MatterInput, options?: MatterOptions) =>
32
37
  baseMatter(input, withYamlEngine(options)),
33
38
  baseMatter,
34
39
  {
40
+ read: (filepath: ReadArgs[0], options?: ReadArgs[1]) =>
41
+ baseMatter.read(filepath, withYamlEngine(options)),
35
42
  stringify: (
36
43
  file: StringifyArgs[0],
37
44
  data: StringifyArgs[1],
@@ -55,8 +55,14 @@ const uiStringsObject = z.object({
55
55
  .default({}),
56
56
  changelog: z
57
57
  .object({
58
+ description: z
59
+ .string()
60
+ .default(
61
+ "Product updates, new features, and fixes from every release."
62
+ ),
58
63
  // `{version}` is replaced with the major line ("2.x") at render time.
59
64
  showReleases: z.string().default("Show {version} releases"),
65
+ title: z.string().default("Changelog"),
60
66
  })
61
67
  .default({}),
62
68
  content: z
@@ -81,6 +87,7 @@ const uiStringsObject = z.object({
81
87
  nav: z
82
88
  .object({
83
89
  back: z.string().default("Back"),
90
+ breadcrumb: z.string().default("Breadcrumb"),
84
91
  closeNavigation: z.string().default("Close navigation"),
85
92
  deprecated: z.string().default("deprecated"),
86
93
  featured: z.string().default("Featured"),
@@ -105,12 +112,14 @@ const uiStringsObject = z.object({
105
112
  .object({
106
113
  lastUpdated: z.string().default("Last updated on"),
107
114
  next: z.string().default("Next"),
115
+ pagination: z.string().default("Pagination"),
108
116
  previous: z.string().default("Previous"),
109
117
  skipToContent: z.string().default("Skip to content"),
110
118
  })
111
119
  .default({}),
112
120
  search: z
113
121
  .object({
122
+ all: z.string().default("All"),
114
123
  allLanguages: z.string().default("All languages"),
115
124
  askAi: z.string().default("Ask AI"),
116
125
  askAiHint: z.string().default("Get an instant answer from AI"),
package/src/core/i18n.ts CHANGED
@@ -74,12 +74,16 @@ export const detectLocale = (
74
74
  parts: string[],
75
75
  i18n: ResolvedI18nConfig
76
76
  ): { locale: string; rest: string[] } => {
77
- const [first] = parts;
78
- const isNonDefault = i18n.locales.some(
79
- (locale) => locale.code !== i18n.defaultLocale && locale.code === first
77
+ // BCP 47 codes are case-insensitive: a conventional lowercase folder
78
+ // (`pt-br/`) must match a configured `pt-BR`. The configured casing is what
79
+ // flows into routes and labels.
80
+ const first = parts[0]?.toLowerCase();
81
+ const matched = i18n.locales.find(
82
+ (locale) =>
83
+ locale.code !== i18n.defaultLocale && locale.code.toLowerCase() === first
80
84
  );
81
- if (first !== undefined && isNonDefault) {
82
- return { locale: first, rest: parts.slice(1) };
85
+ if (matched) {
86
+ return { locale: matched.code, rest: parts.slice(1) };
83
87
  }
84
88
  return { locale: i18n.defaultLocale, rest: parts };
85
89
  };
@@ -116,11 +120,15 @@ export const localePlacement = (
116
120
  // authoring `intro.en.mdx` + `intro.fr.mdx` shares one translation key
117
121
  // instead of routing the default file to a literal `/intro.en`.
118
122
  if (lastDot > base.lastIndexOf("/")) {
119
- const suffix = base.slice(lastDot + 1);
120
- const matched = i18n.locales.some((locale) => locale.code === suffix);
123
+ // Case-insensitive, like `detectLocale`: `intro.pt-br.mdx` matches a
124
+ // configured `pt-BR` and adopts its casing.
125
+ const suffix = base.slice(lastDot + 1).toLowerCase();
126
+ const matched = i18n.locales.find(
127
+ (locale) => locale.code.toLowerCase() === suffix
128
+ );
121
129
  if (matched) {
122
130
  return {
123
- locales: [suffix],
131
+ locales: [matched.code],
124
132
  navPath: `${base.slice(0, lastDot)}${ext}`,
125
133
  };
126
134
  }
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
 
3
3
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
4
4
  import { normalizeRoute } from "../openapi/references.ts";
5
+ import { normalizeXHandle } from "../seo/x-handle.ts";
5
6
  import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
6
7
  import { normalizeBasePath } from "./base-path.ts";
7
8
  import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
@@ -47,6 +48,14 @@ const sidebarMetaSchema = z.strictObject({
47
48
  order: z.number().optional(),
48
49
  });
49
50
 
51
+ /**
52
+ * An X handle, normalized to a leading `@` — `twitter:site`/`twitter:creator`
53
+ * require it, and a handle configured without one is the obvious typo to absorb
54
+ * rather than reject. The layouts normalize again on the way out, since a page's
55
+ * `seo.x.creator` reaches them straight from unvalidated frontmatter.
56
+ */
57
+ const xHandleSchema = z.string().transform(normalizeXHandle).optional();
58
+
50
59
  const seoMetaSchema = z.strictObject({
51
60
  // blume bundles Zod 3; top-level `z.url()` is undefined at runtime and
52
61
  // schemas must stay dual-compatible with consumer projects on Zod 4.
@@ -56,6 +65,8 @@ const seoMetaSchema = z.strictObject({
56
65
  image: z.string().optional(),
57
66
  noindex: z.boolean().default(false),
58
67
  title: z.string().optional(),
68
+ /** Per-page X attribution — a guest post credits its own author. */
69
+ x: z.strictObject({ creator: xHandleSchema }).optional(),
59
70
  });
60
71
 
61
72
  const searchMetaSchema = z.strictObject({
@@ -599,8 +610,11 @@ const aiConfigSchema = z.strictObject({
599
610
  // not in components.tsx — because the config file is executed at build
600
611
  // time while the components file is only statically analyzed. A same-name
601
612
  // entry replaces the built-in serializer.
613
+ // Two-argument `z.record` — the single-argument form throws at
614
+ // schema-construction time under Zod 4 (see uiStringsOverrideSchema).
602
615
  markdownComponents: z
603
616
  .record(
617
+ z.string(),
604
618
  z.custom<ComponentMarkdown>((value) => typeof value === "function", {
605
619
  message: "Expected a serializer function.",
606
620
  })
@@ -773,6 +787,19 @@ const redirectSchema = z.strictObject({
773
787
  to: z.string(),
774
788
  });
775
789
 
790
+ /**
791
+ * X (Twitter) attribution. The account fields feed `twitter:site` (the site's
792
+ * account) and `twitter:creator` (the author's), which is the one piece of X
793
+ * card metadata with no Open Graph equivalent to fall back to — everything else
794
+ * on the card is read from `og:*`.
795
+ */
796
+ const xConfigSchema = z.strictObject({
797
+ /** The author's account, overridable per page via `seo.x.creator`. */
798
+ creator: xHandleSchema,
799
+ /** The site's own account, e.g. `@blume`. */
800
+ handle: xHandleSchema,
801
+ });
802
+
776
803
  const ogConfigSchema = z.strictObject({
777
804
  /**
778
805
  * Generate a per-page Open Graph image. Defaults to on once a deployment
@@ -842,6 +869,8 @@ const seoConfigSchema = z.strictObject({
842
869
  sitemap: z.boolean().default(true),
843
870
  /** Emit schema.org JSON-LD in each page's <head>. */
844
871
  structuredData: z.boolean().default(true),
872
+ /** X (Twitter) account attribution for share cards. */
873
+ x: xConfigSchema.default({}),
845
874
  });
846
875
 
847
876
  const githubConfigSchema = z.strictObject({
@@ -1018,6 +1047,12 @@ const tocConfigSchema = z
1018
1047
  maxLevel: value.maxHeadingLevel ?? 3,
1019
1048
  minLevel: value.minHeadingLevel ?? 2,
1020
1049
  };
1050
+ })
1051
+ // Checked after defaults apply, so `{ minHeadingLevel: 5 }` (default max 3)
1052
+ // is caught too — an inverted range would silently render an empty TOC.
1053
+ .refine((value) => value.minLevel <= value.maxLevel, {
1054
+ message:
1055
+ "toc.minHeadingLevel must be less than or equal to toc.maxHeadingLevel.",
1021
1056
  });
1022
1057
 
1023
1058
  export const blumeConfigSchema = z.strictObject({
@@ -24,6 +24,10 @@ export const ar: UIStringsOverride = {
24
24
  title: "اسأل الذكاء الاصطناعي",
25
25
  },
26
26
  banner: { dismiss: "إغلاق الإعلان" },
27
+ changelog: {
28
+ description: "تحديثات المنتج وملاحظات الإصدارات.",
29
+ title: "سجل التغييرات",
30
+ },
27
31
  feedback: {
28
32
  no: "لا",
29
33
  question: "هل كانت هذه الصفحة مفيدة؟",
@@ -31,13 +35,16 @@ export const ar: UIStringsOverride = {
31
35
  yes: "نعم",
32
36
  },
33
37
  languageSwitcher: { label: "اللغة", untranslated: "غير مترجم" },
38
+ nav: { breadcrumb: "مسار التنقل" },
34
39
  page: {
35
40
  lastUpdated: "آخر تحديث في",
36
41
  next: "التالي",
42
+ pagination: "ترقيم الصفحات",
37
43
  previous: "السابق",
38
44
  skipToContent: "الانتقال إلى المحتوى",
39
45
  },
40
46
  search: {
47
+ all: "الكل",
41
48
  button: "بحث",
42
49
  devOnly: "البحث متاح في إصدار الإنتاج.",
43
50
  label: "البحث في الوثائق",
@@ -24,6 +24,10 @@ export const bg: UIStringsOverride = {
24
24
  title: "Попитай ИИ",
25
25
  },
26
26
  banner: { dismiss: "Затваряне на съобщението" },
27
+ changelog: {
28
+ description: "Актуализации на продукта и бележки към изданията.",
29
+ title: "Дневник на промените",
30
+ },
27
31
  feedback: {
28
32
  no: "Не",
29
33
  question: "Беше ли полезна тази страница?",
@@ -31,13 +35,16 @@ export const bg: UIStringsOverride = {
31
35
  yes: "Да",
32
36
  },
33
37
  languageSwitcher: { label: "Език", untranslated: "Непреведено" },
38
+ nav: { breadcrumb: "Навигационна пътека" },
34
39
  page: {
35
40
  lastUpdated: "Последна актуализация",
36
41
  next: "Напред",
42
+ pagination: "Пагинация",
37
43
  previous: "Назад",
38
44
  skipToContent: "Към съдържанието",
39
45
  },
40
46
  search: {
47
+ all: "Всички",
41
48
  button: "Търсене",
42
49
  devOnly: "Търсенето е достъпно в производствената компилация.",
43
50
  label: "Търсене в документацията",
@@ -24,6 +24,10 @@ export const bn: UIStringsOverride = {
24
24
  title: "AI-কে জিজ্ঞাসা করুন",
25
25
  },
26
26
  banner: { dismiss: "ঘোষণা বন্ধ করুন" },
27
+ changelog: {
28
+ description: "পণ্য আপডেট এবং রিলিজ নোট।",
29
+ title: "পরিবর্তন লগ",
30
+ },
27
31
  feedback: {
28
32
  no: "না",
29
33
  question: "এই পৃষ্ঠাটি কি সহায়ক ছিল?",
@@ -31,13 +35,16 @@ export const bn: UIStringsOverride = {
31
35
  yes: "হ্যাঁ",
32
36
  },
33
37
  languageSwitcher: { label: "ভাষা", untranslated: "অনূদিত নয়" },
38
+ nav: { breadcrumb: "ব্রেডক্রাম্ব" },
34
39
  page: {
35
40
  lastUpdated: "সর্বশেষ আপডেট",
36
41
  next: "পরবর্তী",
42
+ pagination: "পেজিনেশন",
37
43
  previous: "পূর্ববর্তী",
38
44
  skipToContent: "বিষয়বস্তুতে যান",
39
45
  },
40
46
  search: {
47
+ all: "সব",
41
48
  button: "অনুসন্ধান",
42
49
  devOnly: "অনুসন্ধান প্রোডাকশন বিল্ডে উপলব্ধ।",
43
50
  label: "ডকুমেন্টেশন অনুসন্ধান করুন",
@@ -24,6 +24,10 @@ export const ca: UIStringsOverride = {
24
24
  title: "Pregunta a la IA",
25
25
  },
26
26
  banner: { dismiss: "Tanca l'anunci" },
27
+ changelog: {
28
+ description: "Actualitzacions del producte i notes de la versió.",
29
+ title: "Registre de canvis",
30
+ },
27
31
  feedback: {
28
32
  no: "No",
29
33
  question: "Aquesta pàgina t'ha estat útil?",
@@ -31,13 +35,16 @@ export const ca: UIStringsOverride = {
31
35
  yes: "Sí",
32
36
  },
33
37
  languageSwitcher: { label: "Idioma", untranslated: "Sense traduir" },
38
+ nav: { breadcrumb: "Ruta de navegació" },
34
39
  page: {
35
40
  lastUpdated: "Última actualització el",
36
41
  next: "Següent",
42
+ pagination: "Paginació",
37
43
  previous: "Anterior",
38
44
  skipToContent: "Vés al contingut",
39
45
  },
40
46
  search: {
47
+ all: "Tots",
41
48
  button: "Cerca",
42
49
  devOnly: "La cerca està disponible a la compilació de producció.",
43
50
  label: "Cerca a la documentació",
@@ -24,6 +24,10 @@ export const cs: UIStringsOverride = {
24
24
  title: "Zeptat se AI",
25
25
  },
26
26
  banner: { dismiss: "Zavřít oznámení" },
27
+ changelog: {
28
+ description: "Novinky produktu a poznámky k vydání.",
29
+ title: "Seznam změn",
30
+ },
27
31
  feedback: {
28
32
  no: "Ne",
29
33
  question: "Byla tato stránka užitečná?",
@@ -31,13 +35,16 @@ export const cs: UIStringsOverride = {
31
35
  yes: "Ano",
32
36
  },
33
37
  languageSwitcher: { label: "Jazyk", untranslated: "Nepřeloženo" },
38
+ nav: { breadcrumb: "Drobečková navigace" },
34
39
  page: {
35
40
  lastUpdated: "Naposledy aktualizováno",
36
41
  next: "Další",
42
+ pagination: "Stránkování",
37
43
  previous: "Předchozí",
38
44
  skipToContent: "Přejít k obsahu",
39
45
  },
40
46
  search: {
47
+ all: "Vše",
41
48
  button: "Hledat",
42
49
  devOnly: "Vyhledávání je dostupné v produkčním buildu.",
43
50
  label: "Prohledat dokumentaci",
@@ -24,6 +24,10 @@ export const da: UIStringsOverride = {
24
24
  title: "Spørg AI",
25
25
  },
26
26
  banner: { dismiss: "Luk meddelelsen" },
27
+ changelog: {
28
+ description: "Produktopdateringer og udgivelsesnoter.",
29
+ title: "Ændringslog",
30
+ },
27
31
  feedback: {
28
32
  no: "Nej",
29
33
  question: "Var denne side nyttig?",
@@ -31,13 +35,16 @@ export const da: UIStringsOverride = {
31
35
  yes: "Ja",
32
36
  },
33
37
  languageSwitcher: { label: "Sprog", untranslated: "Ikke oversat" },
38
+ nav: { breadcrumb: "Brødkrummesti" },
34
39
  page: {
35
40
  lastUpdated: "Senest opdateret den",
36
41
  next: "Næste",
42
+ pagination: "Paginering",
37
43
  previous: "Forrige",
38
44
  skipToContent: "Spring til indhold",
39
45
  },
40
46
  search: {
47
+ all: "Alle",
41
48
  button: "Søg",
42
49
  devOnly: "Søgning er tilgængelig i produktionsbygningen.",
43
50
  label: "Søg i dokumentationen",
@@ -24,6 +24,10 @@ export const de: UIStringsOverride = {
24
24
  title: "KI fragen",
25
25
  },
26
26
  banner: { dismiss: "Ankündigung schließen" },
27
+ changelog: {
28
+ description: "Produkt-Updates und Versionshinweise.",
29
+ title: "Änderungsprotokoll",
30
+ },
27
31
  feedback: {
28
32
  no: "Nein",
29
33
  question: "War diese Seite hilfreich?",
@@ -31,13 +35,16 @@ export const de: UIStringsOverride = {
31
35
  yes: "Ja",
32
36
  },
33
37
  languageSwitcher: { label: "Sprache", untranslated: "Nicht übersetzt" },
38
+ nav: { breadcrumb: "Brotkrümelnavigation" },
34
39
  page: {
35
40
  lastUpdated: "Zuletzt aktualisiert am",
36
41
  next: "Weiter",
42
+ pagination: "Seitennummerierung",
37
43
  previous: "Zurück",
38
44
  skipToContent: "Zum Inhalt springen",
39
45
  },
40
46
  search: {
47
+ all: "Alle",
41
48
  button: "Suchen",
42
49
  devOnly: "Die Suche ist im Produktions-Build verfügbar.",
43
50
  label: "Dokumentation durchsuchen",
@@ -24,6 +24,10 @@ export const el: UIStringsOverride = {
24
24
  title: "Ρωτήστε την AI",
25
25
  },
26
26
  banner: { dismiss: "Κλείσιμο ανακοίνωσης" },
27
+ changelog: {
28
+ description: "Ενημερώσεις προϊόντος και σημειώσεις έκδοσης.",
29
+ title: "Ιστορικό αλλαγών",
30
+ },
27
31
  feedback: {
28
32
  no: "Όχι",
29
33
  question: "Σας φάνηκε χρήσιμη αυτή η σελίδα;",
@@ -31,13 +35,16 @@ export const el: UIStringsOverride = {
31
35
  yes: "Ναι",
32
36
  },
33
37
  languageSwitcher: { label: "Γλώσσα", untranslated: "Δεν έχει μεταφραστεί" },
38
+ nav: { breadcrumb: "Διαδρομή πλοήγησης" },
34
39
  page: {
35
40
  lastUpdated: "Τελευταία ενημέρωση",
36
41
  next: "Επόμενο",
42
+ pagination: "Σελιδοποίηση",
37
43
  previous: "Προηγούμενο",
38
44
  skipToContent: "Μετάβαση στο περιεχόμενο",
39
45
  },
40
46
  search: {
47
+ all: "Όλα",
41
48
  button: "Αναζήτηση",
42
49
  devOnly: "Η αναζήτηση είναι διαθέσιμη στην έκδοση παραγωγής.",
43
50
  label: "Αναζήτηση στην τεκμηρίωση",