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.
- package/CHANGELOG.md +49 -1
- package/dist/cli/index.js +373 -28
- package/dist/cli/index.js.map +51 -50
- package/dist/types/core/config-input.d.ts +11 -0
- package/dist/types/core/data.d.ts +5 -0
- package/dist/types/core/i18n-ui.d.ts +50 -0
- package/dist/types/core/schema.d.ts +77 -1
- package/dist/types/seo/x-handle.d.ts +12 -0
- package/docs/advanced/api-reference.mdx +1 -1
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/seo.mdx +33 -3
- package/docs/content/components.mdx +1 -1
- package/docs/content/syntax.mdx +2 -2
- package/docs/index.mdx +1 -1
- package/docs/reference/cli.mdx +1 -1
- package/docs/reference/frontmatter.mdx +1 -1
- package/package.json +9 -1
- package/src/ai/mcp/tools.ts +1 -1
- package/src/astro/generate.ts +1 -0
- package/src/astro/templates.ts +51 -7
- package/src/cli/commands/build.ts +25 -8
- package/src/cli/internal-error.ts +9 -4
- package/src/cli/prepare.ts +3 -2
- package/src/components/Icon.astro +2 -1
- package/src/components/islands/ask-ai.tsx +30 -5
- package/src/components/layout/Breadcrumbs.astro +11 -2
- package/src/components/layout/NavTree.astro +2 -2
- package/src/components/layout/PageLayout.astro +40 -1
- package/src/components/layout/Pagination.astro +10 -4
- package/src/components/layout/ReferenceLayout.astro +12 -1
- package/src/components/layout/RootLayout.astro +75 -3
- package/src/components/layout/Search.astro +23 -5
- package/src/core/config-input.ts +11 -0
- package/src/core/data.ts +2 -0
- package/src/core/frontmatter.ts +7 -0
- package/src/core/i18n-ui.ts +9 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/schema.ts +35 -0
- package/src/core/ui-packs/ar.ts +7 -0
- package/src/core/ui-packs/bg.ts +7 -0
- package/src/core/ui-packs/bn.ts +7 -0
- package/src/core/ui-packs/ca.ts +7 -0
- package/src/core/ui-packs/cs.ts +7 -0
- package/src/core/ui-packs/da.ts +7 -0
- package/src/core/ui-packs/de.ts +7 -0
- package/src/core/ui-packs/el.ts +7 -0
- package/src/core/ui-packs/es.ts +7 -0
- package/src/core/ui-packs/fa.ts +7 -0
- package/src/core/ui-packs/fi.ts +7 -0
- package/src/core/ui-packs/fr.ts +7 -0
- package/src/core/ui-packs/he.ts +7 -0
- package/src/core/ui-packs/hi.ts +7 -0
- package/src/core/ui-packs/hr.ts +7 -0
- package/src/core/ui-packs/hu.ts +7 -0
- package/src/core/ui-packs/id.ts +7 -0
- package/src/core/ui-packs/it.ts +7 -0
- package/src/core/ui-packs/ja.ts +7 -0
- package/src/core/ui-packs/ko.ts +7 -0
- package/src/core/ui-packs/nl.ts +7 -0
- package/src/core/ui-packs/no.ts +7 -0
- package/src/core/ui-packs/pl.ts +7 -0
- package/src/core/ui-packs/pt-br.ts +7 -0
- package/src/core/ui-packs/pt.ts +7 -0
- package/src/core/ui-packs/ro.ts +7 -0
- package/src/core/ui-packs/ru.ts +7 -0
- package/src/core/ui-packs/sk.ts +7 -0
- package/src/core/ui-packs/sr.ts +7 -0
- package/src/core/ui-packs/sv.ts +7 -0
- package/src/core/ui-packs/th.ts +7 -0
- package/src/core/ui-packs/tr.ts +7 -0
- package/src/core/ui-packs/uk.ts +7 -0
- package/src/core/ui-packs/vi.ts +7 -0
- package/src/core/ui-packs/zh-tw.ts +7 -0
- package/src/core/ui-packs/zh.ts +7 -0
- package/src/og/card.ts +4 -2
- package/src/og/dimensions.ts +12 -0
- package/src/openapi/render-mdx.ts +62 -1
- package/src/seo/jsonld.ts +7 -1
- 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 {
|
|
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:
|
|
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
|
|
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
|
-
(
|
|
299
|
-
|
|
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(
|
|
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)
|
package/src/core/config-input.ts
CHANGED
|
@@ -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
|
/**
|
package/src/core/frontmatter.ts
CHANGED
|
@@ -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],
|
package/src/core/i18n-ui.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 (
|
|
82
|
-
return { locale:
|
|
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
|
-
|
|
120
|
-
|
|
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: [
|
|
131
|
+
locales: [matched.code],
|
|
124
132
|
navPath: `${base.slice(0, lastDot)}${ext}`,
|
|
125
133
|
};
|
|
126
134
|
}
|
package/src/core/schema.ts
CHANGED
|
@@ -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({
|
package/src/core/ui-packs/ar.ts
CHANGED
|
@@ -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: "البحث في الوثائق",
|
package/src/core/ui-packs/bg.ts
CHANGED
|
@@ -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: "Търсене в документацията",
|
package/src/core/ui-packs/bn.ts
CHANGED
|
@@ -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: "ডকুমেন্টেশন অনুসন্ধান করুন",
|
package/src/core/ui-packs/ca.ts
CHANGED
|
@@ -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ó",
|
package/src/core/ui-packs/cs.ts
CHANGED
|
@@ -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",
|
package/src/core/ui-packs/da.ts
CHANGED
|
@@ -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",
|
package/src/core/ui-packs/de.ts
CHANGED
|
@@ -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",
|
package/src/core/ui-packs/el.ts
CHANGED
|
@@ -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: "Αναζήτηση στην τεκμηρίωση",
|