blume 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/dist/cli/index.js +3290 -254
- package/dist/cli/index.js.map +79 -67
- package/dist/types/core/base-path.d.ts +5 -0
- package/dist/types/core/config-input.d.ts +82 -6
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +19 -2
- package/dist/types/core/sources/types.d.ts +5 -0
- package/dist/types/core/types.d.ts +4 -3
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/ai.mdx +16 -2
- package/docs/configuration/index.mdx +26 -0
- package/docs/configuration/search.mdx +1 -3
- package/docs/content/i18n.mdx +13 -1
- package/docs/content/navigation.mdx +11 -0
- package/docs/reference/cli.mdx +4 -0
- package/docs/reference/frontmatter.mdx +33 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +80 -0
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/link-headers.ts +4 -3
- package/src/ai/llms.ts +4 -2
- package/src/ai/markdown.ts +34 -1
- package/src/ai/mcp/data.ts +10 -4
- package/src/ai/mcp/server.ts +74 -3
- package/src/ai/mcp/tools.ts +2 -2
- package/src/astro/generate.ts +2 -2
- package/src/astro/integration.ts +6 -2
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +74 -21
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +145 -34
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/index.ts +2 -0
- package/src/components/Icon.astro +2 -7
- package/src/components/content/Step.astro +3 -8
- package/src/components/content/Tab.astro +20 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +4 -4
- package/src/components/layout/PageActions.astro +12 -7
- package/src/components/layout/Search.astro +15 -20
- package/src/components/layout/search/orama.ts +3 -1
- package/src/core/base-path.ts +9 -0
- package/src/core/config-input.ts +84 -6
- package/src/core/graph.ts +46 -2
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/i18n.ts +31 -0
- package/src/core/nav-diagnostics.ts +13 -34
- package/src/core/project-graph.ts +13 -2
- package/src/core/schema.ts +174 -74
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/types.ts +5 -0
- package/src/core/types.ts +4 -3
- package/src/core/ui-packs/ar.ts +42 -1
- package/src/core/ui-packs/bg.ts +42 -1
- package/src/core/ui-packs/bn.ts +42 -1
- package/src/core/ui-packs/ca.ts +44 -1
- package/src/core/ui-packs/cs.ts +42 -1
- package/src/core/ui-packs/da.ts +42 -1
- package/src/core/ui-packs/de.ts +42 -1
- package/src/core/ui-packs/el.ts +44 -1
- package/src/core/ui-packs/es.ts +44 -1
- package/src/core/ui-packs/fa.ts +42 -1
- package/src/core/ui-packs/fi.ts +42 -1
- package/src/core/ui-packs/fr.ts +44 -1
- package/src/core/ui-packs/he.ts +42 -1
- package/src/core/ui-packs/hi.ts +42 -1
- package/src/core/ui-packs/hr.ts +42 -1
- package/src/core/ui-packs/hu.ts +42 -1
- package/src/core/ui-packs/id.ts +42 -1
- package/src/core/ui-packs/it.ts +44 -1
- package/src/core/ui-packs/ja.ts +44 -1
- package/src/core/ui-packs/ko.ts +44 -1
- package/src/core/ui-packs/nl.ts +42 -1
- package/src/core/ui-packs/no.ts +42 -1
- package/src/core/ui-packs/pl.ts +42 -1
- package/src/core/ui-packs/pt-br.ts +44 -1
- package/src/core/ui-packs/pt.ts +44 -1
- package/src/core/ui-packs/ro.ts +42 -1
- package/src/core/ui-packs/ru.ts +42 -1
- package/src/core/ui-packs/sk.ts +42 -1
- package/src/core/ui-packs/sr.ts +42 -1
- package/src/core/ui-packs/sv.ts +42 -1
- package/src/core/ui-packs/th.ts +44 -1
- package/src/core/ui-packs/tr.ts +42 -1
- package/src/core/ui-packs/uk.ts +42 -1
- package/src/core/ui-packs/vi.ts +44 -1
- package/src/core/ui-packs/zh-tw.ts +44 -1
- package/src/core/ui-packs/zh.ts +44 -1
- package/src/deploy/adapter-output.ts +44 -5
- package/src/deploy/cloudflare-negotiation.ts +527 -0
- package/src/deploy/redirects.ts +13 -0
- package/src/deploy/vercel-negotiation.ts +30 -13
- package/src/eval/agents.ts +1 -1
- package/src/search/documents.ts +11 -0
- package/src/search/facets.ts +33 -0
- package/src/search/orama-index.ts +48 -6
- package/src/search/popular-icon.ts +33 -0
- package/src/theme/fonts.ts +3 -1
- package/src/theme/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +148 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +360 -0
- package/src/translate/run.ts +376 -0
- package/src/translate/validate.ts +171 -0
- package/src/translate/work-list.ts +0 -0
|
@@ -36,7 +36,7 @@ const brandText = logo?.text ?? site.title;
|
|
|
36
36
|
logoSvg && (
|
|
37
37
|
<span
|
|
38
38
|
aria-hidden="true"
|
|
39
|
-
class="inline-flex h-5 items-center [&>svg]:h-5 [&>svg]:w-auto"
|
|
39
|
+
class="inline-flex h-5 shrink-0 items-center [&>svg]:h-5 [&>svg]:w-auto"
|
|
40
40
|
set:html={logoSvg}
|
|
41
41
|
/>
|
|
42
42
|
)
|
|
@@ -47,7 +47,7 @@ const brandText = logo?.text ?? site.title;
|
|
|
47
47
|
(logoLight === logoDark ? (
|
|
48
48
|
<img
|
|
49
49
|
alt={logoAlt}
|
|
50
|
-
class="h-5 w-auto"
|
|
50
|
+
class="h-5 w-auto shrink-0"
|
|
51
51
|
height={logoLightDimensions?.height}
|
|
52
52
|
src={withBase(logoLight)}
|
|
53
53
|
width={logoLightDimensions?.width}
|
|
@@ -56,14 +56,14 @@ const brandText = logo?.text ?? site.title;
|
|
|
56
56
|
<>
|
|
57
57
|
<img
|
|
58
58
|
alt={logoAlt}
|
|
59
|
-
class="h-5 w-auto dark:hidden"
|
|
59
|
+
class="h-5 w-auto shrink-0 dark:hidden"
|
|
60
60
|
height={logoLightDimensions?.height}
|
|
61
61
|
src={withBase(logoLight)}
|
|
62
62
|
width={logoLightDimensions?.width}
|
|
63
63
|
/>
|
|
64
64
|
<img
|
|
65
65
|
alt={logoAlt}
|
|
66
|
-
class="hidden h-5 w-auto dark:block"
|
|
66
|
+
class="hidden h-5 w-auto shrink-0 dark:block"
|
|
67
67
|
height={logoDarkDimensions?.height}
|
|
68
68
|
src={withBase(logoDark)}
|
|
69
69
|
width={logoDarkDimensions?.width}
|
|
@@ -44,14 +44,19 @@ const LOGOS: Record<string, string> = {
|
|
|
44
44
|
v0: '<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 147 70" xmlns="http://www.w3.org/2000/svg"><path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z"/><path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z"/></svg>',
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
// Brand names stay verbatim; the surrounding "Open in" phrase localizes via
|
|
48
|
+
// the `actions.openIn` template.
|
|
47
49
|
const PROVIDERS = [
|
|
48
|
-
{ key: "v0",
|
|
49
|
-
{ key: "chatgpt",
|
|
50
|
-
{ key: "claude",
|
|
51
|
-
{ key: "t3",
|
|
52
|
-
{ key: "scira",
|
|
53
|
-
{ key: "cursor",
|
|
54
|
-
]
|
|
50
|
+
{ key: "v0", name: "v0" },
|
|
51
|
+
{ key: "chatgpt", name: "ChatGPT" },
|
|
52
|
+
{ key: "claude", name: "Claude" },
|
|
53
|
+
{ key: "t3", name: "T3 Chat" },
|
|
54
|
+
{ key: "scira", name: "Scira" },
|
|
55
|
+
{ key: "cursor", name: "Cursor" },
|
|
56
|
+
].map((provider) => ({
|
|
57
|
+
...provider,
|
|
58
|
+
label: a.openIn.replace("{name}", provider.name),
|
|
59
|
+
}));
|
|
55
60
|
|
|
56
61
|
// Top-level actions match the ToC links: flat, no background/padding, color
|
|
57
62
|
// shift on hover only.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
3
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
4
4
|
import type { Navigation } from "../../core/types.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { resolvePopularIconMarkup } from "../../search/popular-icon.ts";
|
|
6
6
|
import Icon from "../Icon.astro";
|
|
7
7
|
import { flattenPages } from "./nav-utils.ts";
|
|
8
8
|
|
|
@@ -28,23 +28,15 @@ const { askEnabled = false, navigation, popularPages, strings, locale } =
|
|
|
28
28
|
// default, matching the pattern PageActions uses for its own dictionary.
|
|
29
29
|
const s = { ...EN_UI.search, ...strings };
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// markup rather than a name it can't resolve. Same approach as AskAI.astro.
|
|
34
|
-
// A name outside the set falls through to the island's own file glyph.
|
|
35
|
-
const iconSvg = (name: string | undefined): string | undefined => {
|
|
36
|
-
const resolved = name ? resolveIcon(name) : null;
|
|
37
|
-
return resolved
|
|
38
|
-
? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${resolved.viewBox}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${resolved.body}</svg>`
|
|
39
|
-
: undefined;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// Pages shown in the empty state, before the user has typed anything. Only
|
|
43
|
-
// curated entries carry an icon; sidebar-derived rows keep the file glyph.
|
|
31
|
+
// Resolve popular icons to markup here (icon set is server-only). Unknown
|
|
32
|
+
// names fall through to the island's file glyph.
|
|
44
33
|
const popular =
|
|
45
34
|
popularPages && popularPages.length > 0
|
|
46
35
|
? popularPages.map((page) => ({
|
|
47
|
-
icon:
|
|
36
|
+
icon: resolvePopularIconMarkup(
|
|
37
|
+
page.icon,
|
|
38
|
+
import.meta.env.BASE_URL ?? "/"
|
|
39
|
+
),
|
|
48
40
|
label: page.label,
|
|
49
41
|
route: page.route,
|
|
50
42
|
}))
|
|
@@ -68,15 +60,18 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
68
60
|
data-i18n-results={s.results}
|
|
69
61
|
data-locale={locale || undefined}
|
|
70
62
|
>
|
|
63
|
+
{/* The label and shortcut hint wait until `lg`: below it the hamburger and
|
|
64
|
+
inline tab bar share the header row, and a full-width search field would
|
|
65
|
+
press into the language switcher. */}
|
|
71
66
|
<button
|
|
72
67
|
aria-label={s.button}
|
|
73
|
-
class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground
|
|
68
|
+
class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground lg:min-w-48"
|
|
74
69
|
data-blume-search-open
|
|
75
70
|
type="button"
|
|
76
71
|
>
|
|
77
72
|
<Icon name="search" size={16} />
|
|
78
|
-
<span class="flex-1 text-start max-
|
|
79
|
-
<kbd class="font-mono text-[0.7rem] max-
|
|
73
|
+
<span class="flex-1 text-start max-lg:hidden">{s.button}</span>
|
|
74
|
+
<kbd class="font-mono text-[0.7rem] max-lg:hidden" data-blume-search-kbd
|
|
80
75
|
>⌘K</kbd
|
|
81
76
|
>
|
|
82
77
|
</button>
|
|
@@ -626,8 +621,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
626
621
|
const href = prefixBase(import.meta.env.BASE_URL, url);
|
|
627
622
|
el.href = href;
|
|
628
623
|
el.className = ROW_CLASS;
|
|
629
|
-
// `icon` is server-resolved markup
|
|
630
|
-
//
|
|
624
|
+
// `icon` is server-resolved markup (built-in Lucide, `<img>`, or
|
|
625
|
+
// config-authored inline SVG) — the label still goes through `escapeHtml`.
|
|
631
626
|
el.innerHTML = `
|
|
632
627
|
<span class="mt-0.5 shrink-0 text-muted-foreground">${icon ?? svg("file")}</span>
|
|
633
628
|
<span class="flex-1">
|
|
@@ -23,7 +23,9 @@ export const createSearch = async (opts: {
|
|
|
23
23
|
const db = await buildOramaIndex(documents, opts.locale);
|
|
24
24
|
|
|
25
25
|
return async (query, options) => {
|
|
26
|
-
const docs = await queryOramaIndex(db, query, RESULT_POOL,
|
|
26
|
+
const docs = await queryOramaIndex(db, query, RESULT_POOL, {
|
|
27
|
+
locale: options?.locale,
|
|
28
|
+
});
|
|
27
29
|
return buildResult(docs as IndexedDocument[], query, options?.section);
|
|
28
30
|
};
|
|
29
31
|
};
|
package/src/core/base-path.ts
CHANGED
|
@@ -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,
|
package/src/core/config-input.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
/**
|
|
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
|
}
|
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 =
|
|
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
|
-
|
|
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
|
};
|
package/src/core/i18n-ui.ts
CHANGED
|
@@ -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
|
|
12
|
-
|
|
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
|
|
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.
|
|
88
|
-
*
|
|
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
|
-
|
|
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
|
|
147
|
-
// known-key split in `normalizeEntry` stays off the default
|
|
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;
|