blume 1.4.3 → 1.5.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 +17 -0
- package/dist/cli/index.js +1621 -576
- package/dist/cli/index.js.map +109 -104
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/config-input.d.ts +79 -27
- package/dist/types/core/config.d.ts +2 -1
- package/dist/types/core/data.d.ts +16 -1
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/i18n-ui.d.ts +12 -0
- package/dist/types/core/schema.d.ts +112 -15
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +7 -3
- package/dist/types/core/types.d.ts +43 -2
- package/dist/types/core/ui-packs/index.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +6 -5
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/meta.mdx +2 -1
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +35 -1
- package/docs/content/versioning.mdx +106 -0
- package/docs/reference/cli.mdx +1 -0
- package/docs/reference/frontmatter.mdx +3 -0
- package/package.json +3 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/agent-readability.ts +37 -10
- package/src/ai/ask-context.ts +5 -1
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +40 -16
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +28 -11
- package/src/ai/mcp/server.ts +183 -38
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +6 -2
- package/src/astro/generate.ts +54 -29
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +16 -9
- package/src/astro/templates.ts +152 -33
- package/src/audit/agent.ts +2 -2
- package/src/audit/checks/content.ts +26 -11
- package/src/audit/checks/dns-aid.ts +3 -0
- package/src/audit/checks/indexability.ts +24 -6
- package/src/audit/checks/llms.ts +9 -4
- package/src/audit/checks/network.ts +2 -0
- package/src/audit/checks/social.ts +18 -10
- package/src/audit/crawl.ts +37 -9
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +5 -2
- package/src/audit/snapshot.ts +2 -4
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +9 -4
- package/src/cli/commands/build.ts +15 -9
- package/src/cli/commands/dev.ts +2 -0
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eval.ts +7 -3
- package/src/cli/commands/init.ts +9 -9
- package/src/cli/commands/mcp-stdio.ts +3 -0
- package/src/cli/commands/translate.ts +14 -3
- package/src/cli/commands/version.ts +85 -0
- package/src/cli/dev-lock.ts +31 -10
- package/src/cli/eject-scripts.ts +17 -2
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +22 -15
- package/src/cli/internal-error.ts +1 -0
- package/src/components/content/auto-type-table.ts +3 -0
- package/src/components/content/diff.ts +9 -5
- package/src/components/content/github-info.ts +2 -0
- package/src/components/islands/ask-ai.tsx +33 -25
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/islands/webmcp.ts +49 -12
- package/src/components/layout/Header.astro +25 -1
- package/src/components/layout/NavSelector.astro +11 -2
- package/src/components/layout/NavTree.astro +4 -2
- package/src/components/layout/RootLayout.astro +18 -0
- package/src/components/layout/Search.astro +77 -13
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/hydration-hint.ts +1 -1
- package/src/components/layout/nav-utils.ts +1 -4
- package/src/components/layout/overrides.ts +25 -12
- package/src/components/layout/search/algolia.ts +18 -5
- package/src/components/layout/search/endpoint.ts +3 -0
- package/src/components/layout/search/flexsearch.ts +23 -7
- package/src/components/layout/search/orama-cloud.ts +1 -1
- package/src/components/layout/search/orama.ts +4 -1
- package/src/components/layout/search/pagefind.ts +2 -0
- package/src/components/layout/search/types.ts +13 -1
- package/src/components/layout/search/typesense.ts +19 -3
- package/src/components/openapi/ApiOverview.astro +32 -6
- package/src/components/openapi/AsyncApiOperation.astro +237 -0
- package/src/components/openapi/Bindings.astro +89 -0
- package/src/components/openapi/MethodBadge.astro +3 -0
- package/src/components/openapi/Operation.astro +7 -2
- package/src/components/openapi/PanelTabs.astro +131 -0
- package/src/components/openapi/ParametersTable.astro +2 -0
- package/src/components/openapi/RequestPanel.astro +12 -119
- package/src/components/openapi/async-snippets.ts +174 -0
- package/src/components/openapi/async.ts +348 -0
- package/src/components/openapi/helpers.ts +52 -20
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +88 -27
- package/src/core/config.ts +20 -7
- package/src/core/content.ts +3 -1
- package/src/core/data.ts +16 -1
- package/src/core/define-components.ts +5 -0
- package/src/core/diagnostics.ts +46 -38
- package/src/core/frontmatter.ts +33 -7
- package/src/core/graph.ts +137 -53
- package/src/core/i18n-ui.ts +15 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/load-module.ts +1 -0
- package/src/core/manifest.ts +92 -3
- package/src/core/meta.ts +44 -14
- package/src/core/nav-diagnostics.ts +3 -3
- package/src/core/navigation.ts +247 -67
- package/src/core/project-graph.ts +15 -3
- package/src/core/schema.ts +213 -67
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +6 -0
- package/src/core/sources/github-releases.ts +39 -31
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +67 -20
- package/src/core/sources/notion.ts +49 -17
- package/src/core/sources/portable-text.ts +32 -11
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +4 -0
- package/src/core/sources/watch.ts +1 -1
- package/src/core/standard-schema.ts +9 -3
- package/src/core/text-width.ts +26 -0
- package/src/core/tsconfig-aliases.ts +9 -5
- package/src/core/types.ts +45 -2
- package/src/core/ui-packs/index.ts +9 -1
- package/src/core/version-cut.ts +301 -0
- package/src/core/version.ts +2 -0
- package/src/core/versions.ts +170 -0
- package/src/deploy/adapter-output.ts +5 -2
- package/src/deploy/cloudflare-negotiation.ts +25 -10
- package/src/deploy/sitemap.ts +33 -1
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/report.ts +4 -4
- package/src/eval/run.ts +2 -2
- package/src/eval/schema.ts +1 -1
- package/src/markdown/base-links.ts +6 -6
- package/src/markdown/directives.ts +7 -1
- package/src/markdown/heading-anchors.ts +17 -6
- package/src/markdown/index.ts +73 -24
- package/src/markdown/inline-code.ts +14 -2
- package/src/markdown/language-icon.ts +6 -2
- package/src/markdown/mdast.ts +18 -4
- package/src/markdown/package-commands.ts +6 -8
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +30 -11
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +126 -57
- package/src/openapi/parse.ts +97 -5
- package/src/openapi/references.ts +12 -10
- package/src/openapi/render-mdx.ts +73 -34
- package/src/openapi/scalar.ts +6 -8
- package/src/openapi/source.ts +98 -28
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +25 -5
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +66 -20
- package/src/search/popular.ts +10 -5
- package/src/search/providers.ts +2 -2
- package/src/search/sync/index.ts +2 -0
- package/src/search/sync/typesense.ts +4 -2
- package/src/seo/jsonld.ts +24 -6
- package/src/seo/x-handle.ts +8 -3
- package/src/theme/chrome-icons.ts +7 -2
- package/src/theme/fonts.ts +8 -4
- package/src/theme/icons.ts +4 -2
- package/src/theme/palette.ts +22 -14
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +9 -5
- package/src/translate/run.ts +10 -4
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
* Values are Lucide inner-SVG markup; the client `svg()` helpers wrap them in an
|
|
13
13
|
* `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" …>`.
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
/** Inner-SVG markup keyed by the icon name a client script requests. */
|
|
16
|
+
interface ChromeIconSet {
|
|
17
|
+
[name: string]: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const chromeIcons: ChromeIconSet = {
|
|
16
21
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
17
22
|
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
|
18
23
|
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
|
|
19
24
|
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
20
25
|
sparkles:
|
|
21
|
-
'<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1
|
|
26
|
+
'<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1-1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/><path d="M5 3v4"/><path d="M3 5h4"/><path d="M19 17v4"/><path d="M17 19h4"/>',
|
|
22
27
|
};
|
package/src/theme/fonts.ts
CHANGED
|
@@ -82,11 +82,11 @@ export type FontEntry =
|
|
|
82
82
|
variants: LocalFontVariant[];
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
-
const FALLBACKS
|
|
85
|
+
const FALLBACKS = {
|
|
86
86
|
mono: ["ui-monospace", "SF Mono", "Menlo", "monospace"],
|
|
87
87
|
sans: ["ui-sans-serif", "system-ui", "sans-serif"],
|
|
88
88
|
serif: ["ui-serif", "Georgia", "serif"],
|
|
89
|
-
}
|
|
89
|
+
} satisfies Record<FontCategory, string[]>;
|
|
90
90
|
|
|
91
91
|
/** Slug -> Google family + weights + fallback category. Keep keys alphabetical. */
|
|
92
92
|
export const GOOGLE_FONTS = {
|
|
@@ -223,12 +223,16 @@ const SLOTS: FontSlot[] = ["display", "body", "mono"];
|
|
|
223
223
|
const slotCategory = (slot: FontSlot): FontCategory =>
|
|
224
224
|
slot === "mono" ? "mono" : "sans";
|
|
225
225
|
|
|
226
|
+
/** Whether a slot value is the slug-string form (vs a custom font object). */
|
|
227
|
+
const isSlugValue = (value: FontValue): value is string =>
|
|
228
|
+
typeof value === "string";
|
|
229
|
+
|
|
226
230
|
/** A slot value normalized into an entry, or null for an unknown slug string. */
|
|
227
231
|
const resolveFontValue = (
|
|
228
232
|
slot: FontSlot,
|
|
229
233
|
value: FontValue
|
|
230
234
|
): FontEntry | null => {
|
|
231
|
-
if (
|
|
235
|
+
if (isSlugValue(value)) {
|
|
232
236
|
if (!isFontSlug(value)) {
|
|
233
237
|
return null;
|
|
234
238
|
}
|
|
@@ -320,7 +324,7 @@ export const buildFontEntries = (fonts: FontsConfig): FontEntry[] => {
|
|
|
320
324
|
|
|
321
325
|
/** The slug backing a slot's CSS variable, or null for an unknown slug string. */
|
|
322
326
|
const slotSlug = (value: FontValue): string | null => {
|
|
323
|
-
if (
|
|
327
|
+
if (isSlugValue(value)) {
|
|
324
328
|
return isFontSlug(value) ? value : null;
|
|
325
329
|
}
|
|
326
330
|
return slugifyFontName(value.name);
|
package/src/theme/icons.ts
CHANGED
|
@@ -17,9 +17,11 @@ import { getIconData, iconToSVG } from "@iconify/utils";
|
|
|
17
17
|
// strips it when externalizing) and then rejects the module, whereas `require`
|
|
18
18
|
// of a JSON file needs no attribute and works under both Node and Bun.
|
|
19
19
|
const requireJson = createRequire(import.meta.url);
|
|
20
|
+
// SAFETY: the required file is the Iconify-published icon-set JSON, whose
|
|
21
|
+
// shape is exactly `IconifyJSON`.
|
|
20
22
|
const loadSet = (pkg: string): IconifyJSON => requireJson(pkg) as IconifyJSON;
|
|
21
23
|
|
|
22
|
-
const SETS
|
|
24
|
+
const SETS = {
|
|
23
25
|
lucide: loadSet("@iconify-json/lucide/icons.json"),
|
|
24
26
|
};
|
|
25
27
|
|
|
@@ -27,7 +29,7 @@ const SETS: Record<string, IconifyJSON> = {
|
|
|
27
29
|
const DEFAULT_SET = "lucide";
|
|
28
30
|
|
|
29
31
|
/** Explicit `prefix:name` prefixes. Lucide is the only bundled set. */
|
|
30
|
-
const PREFIX_SETS
|
|
32
|
+
const PREFIX_SETS = {
|
|
31
33
|
lucide: "lucide",
|
|
32
34
|
};
|
|
33
35
|
|
package/src/theme/palette.ts
CHANGED
|
@@ -7,7 +7,7 @@ const FALLBACK_ACCENT = "oklch(0.62 0.16 250)";
|
|
|
7
7
|
* preset colors: the theme CSS and the OG card (og/card.ts) both resolve from
|
|
8
8
|
* this table, so a site and its social cards can't disagree about "blue".
|
|
9
9
|
*/
|
|
10
|
-
export const ACCENTS
|
|
10
|
+
export const ACCENTS = {
|
|
11
11
|
blue: FALLBACK_ACCENT,
|
|
12
12
|
green: "oklch(0.6 0.16 150)",
|
|
13
13
|
orange: "oklch(0.68 0.17 50)",
|
|
@@ -15,7 +15,16 @@ export const ACCENTS: Record<string, string> = {
|
|
|
15
15
|
purple: "oklch(0.58 0.2 290)",
|
|
16
16
|
red: "oklch(0.58 0.22 25)",
|
|
17
17
|
teal: "oklch(0.6 0.12 195)",
|
|
18
|
-
}
|
|
18
|
+
} satisfies Record<string, string>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a raw config value names an accent preset. `hasOwn` keeps a value
|
|
22
|
+
* like "constructor" from resolving an Object.prototype member — which would
|
|
23
|
+
* stringify a function into the generated CSS, breaking the rule (the exact
|
|
24
|
+
* breakout {@link safeColor} exists to prevent).
|
|
25
|
+
*/
|
|
26
|
+
export const isAccentPreset = (value: string): value is keyof typeof ACCENTS =>
|
|
27
|
+
Object.hasOwn(ACCENTS, value);
|
|
19
28
|
|
|
20
29
|
// Characters valid in a CSS color value (hex, rgb/hsl/oklch functions, named
|
|
21
30
|
// colors). Anything else — notably `;`, `{`, `}` — could break out of the
|
|
@@ -26,27 +35,20 @@ const CSS_COLOR = /^[\w\s#%.,()/+-]+$/u;
|
|
|
26
35
|
const safeColor = (value: string, fallback: string): string =>
|
|
27
36
|
CSS_COLOR.test(value.trim()) ? value.trim() : fallback;
|
|
28
37
|
|
|
29
|
-
/**
|
|
30
|
-
* Resolve a named preset or fall back to {@link safeColor}. `hasOwn` keeps a
|
|
31
|
-
* value like "constructor" from resolving an Object.prototype member — which
|
|
32
|
-
* would stringify a function into the generated CSS, breaking the rule (the
|
|
33
|
-
* exact breakout safeColor exists to prevent).
|
|
34
|
-
*/
|
|
38
|
+
/** Resolve a named preset or fall back to {@link safeColor}. */
|
|
35
39
|
const presetOrColor = (value: string): string =>
|
|
36
|
-
|
|
37
|
-
? (ACCENTS[value] as string)
|
|
38
|
-
: safeColor(value, FALLBACK_ACCENT);
|
|
40
|
+
isAccentPreset(value) ? ACCENTS[value] : safeColor(value, FALLBACK_ACCENT);
|
|
39
41
|
|
|
40
42
|
/** Like {@link safeColor} but drops an unsafe/absent value to `null`. */
|
|
41
43
|
const safeColorOrNull = (value: string | undefined): string | null =>
|
|
42
44
|
value && CSS_COLOR.test(value.trim()) ? value.trim() : null;
|
|
43
45
|
|
|
44
|
-
const RADII
|
|
46
|
+
const RADII = {
|
|
45
47
|
lg: "0.75rem",
|
|
46
48
|
md: "0.5rem",
|
|
47
49
|
none: "0",
|
|
48
50
|
sm: "0.25rem",
|
|
49
|
-
}
|
|
51
|
+
} satisfies Record<ResolvedConfig["theme"]["radius"], string>;
|
|
50
52
|
|
|
51
53
|
const cssString = (value: string): string => JSON.stringify(value);
|
|
52
54
|
|
|
@@ -116,6 +118,12 @@ ${tokens.join("\n")}
|
|
|
116
118
|
`;
|
|
117
119
|
};
|
|
118
120
|
|
|
121
|
+
/** Per-mode accent CSS colors. */
|
|
122
|
+
export interface AccentColors {
|
|
123
|
+
dark: string;
|
|
124
|
+
light: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
119
127
|
/**
|
|
120
128
|
* Resolve the configured accent to per-mode CSS colors. A named accent
|
|
121
129
|
* resolves to its preset; any other value is treated as a raw CSS color so
|
|
@@ -125,7 +133,7 @@ ${tokens.join("\n")}
|
|
|
125
133
|
*/
|
|
126
134
|
export const resolveAccent = (
|
|
127
135
|
theme: ResolvedConfig["theme"]
|
|
128
|
-
):
|
|
136
|
+
): AccentColors => ({
|
|
129
137
|
dark: presetOrColor(theme.accent.dark),
|
|
130
138
|
light: presetOrColor(theme.accent.light),
|
|
131
139
|
});
|
package/src/translate/meta.ts
CHANGED
|
@@ -39,6 +39,15 @@ export interface TranslatableMeta {
|
|
|
39
39
|
|
|
40
40
|
const META_FILES = ["**/meta.ts", "**/meta.js", "**/meta.mjs"];
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Whether a loaded meta module default-exports a factory function. Generic so
|
|
44
|
+
* it can decode the loader's untyped module value at this boundary.
|
|
45
|
+
*/
|
|
46
|
+
const isFactoryModule = <T>(
|
|
47
|
+
value: T
|
|
48
|
+
): value is T & ((...args: never[]) => FolderMeta) =>
|
|
49
|
+
typeof value === "function";
|
|
50
|
+
|
|
42
51
|
/** Where a locale's generated meta module lives (always written as `meta.ts`). */
|
|
43
52
|
export const metaTargetPath = (
|
|
44
53
|
meta: TranslatableMeta,
|
|
@@ -92,7 +101,7 @@ export const discoverTranslatableMeta = async (
|
|
|
92
101
|
readFile(file, "utf-8"),
|
|
93
102
|
load(file),
|
|
94
103
|
]);
|
|
95
|
-
if (
|
|
104
|
+
if (isFactoryModule(mod)) {
|
|
96
105
|
diagnostics.push({
|
|
97
106
|
code: "BLUME_TRANSLATE_META_FACTORY",
|
|
98
107
|
file,
|
|
@@ -130,14 +139,14 @@ export const generateMetaModule = (
|
|
|
130
139
|
meta: FolderMeta,
|
|
131
140
|
translatedTitle: string
|
|
132
141
|
): string => {
|
|
133
|
-
const data
|
|
142
|
+
const data = {
|
|
134
143
|
...meta,
|
|
135
144
|
title: translatedTitle,
|
|
136
145
|
};
|
|
137
|
-
const lines = Object.
|
|
138
|
-
.toSorted()
|
|
139
|
-
.filter((
|
|
140
|
-
.map((key) => ` ${key}: ${JSON.stringify(
|
|
146
|
+
const lines = Object.entries(data)
|
|
147
|
+
.toSorted(([a], [b]) => (a < b ? -1 : 1))
|
|
148
|
+
.filter(([, value]) => value !== undefined)
|
|
149
|
+
.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`);
|
|
141
150
|
return [
|
|
142
151
|
"// Generated by `blume translate` — edit the default locale's meta file",
|
|
143
152
|
"// and rerun the translation instead of editing this copy.",
|
package/src/translate/report.ts
CHANGED
|
@@ -29,17 +29,17 @@ import type {
|
|
|
29
29
|
|
|
30
30
|
const ESC = String.fromCodePoint(27);
|
|
31
31
|
|
|
32
|
-
const GLYPH
|
|
32
|
+
const GLYPH = {
|
|
33
33
|
failed: "✖",
|
|
34
34
|
partial: "!",
|
|
35
35
|
translated: "✔",
|
|
36
|
-
}
|
|
36
|
+
} satisfies Record<TranslateItemStatus, string>;
|
|
37
37
|
|
|
38
|
-
const STATUS_COLOR
|
|
38
|
+
const STATUS_COLOR = {
|
|
39
39
|
failed: colors.red,
|
|
40
40
|
partial: colors.yellow,
|
|
41
41
|
translated: colors.green,
|
|
42
|
-
}
|
|
42
|
+
} satisfies Record<TranslateItemStatus, ColorFunction>;
|
|
43
43
|
|
|
44
44
|
export const SPINNER_FRAMES = [
|
|
45
45
|
"⠋",
|
|
@@ -76,8 +76,12 @@ export const spinnerLine = (
|
|
|
76
76
|
total: number,
|
|
77
77
|
frame: number
|
|
78
78
|
): string => {
|
|
79
|
+
// SAFETY: the renderer only paints while at least one item is active, so the
|
|
80
|
+
// oldest active entry exists.
|
|
79
81
|
const first = active[0] as WorkItem;
|
|
80
82
|
const more = active.length > 1 ? ` (+${active.length - 1} more)` : "";
|
|
83
|
+
// SAFETY: `frame % SPINNER_FRAMES.length` is always an index into the
|
|
84
|
+
// non-empty frames array.
|
|
81
85
|
return ` ${colors.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length] as string)} ${itemLabel(first)}${more} ${colors.dim(`${done}/${total}`)}`;
|
|
82
86
|
};
|
|
83
87
|
|
|
@@ -216,7 +220,7 @@ export const checkReportJson = (workList: TranslateWorkList): string => {
|
|
|
216
220
|
};
|
|
217
221
|
|
|
218
222
|
/** One run result lowered to JSON-friendly, root-relative fields. */
|
|
219
|
-
const resultJson = (result: TranslateItemResult)
|
|
223
|
+
const resultJson = (result: TranslateItemResult) => ({
|
|
220
224
|
costUsd: result.costUsd,
|
|
221
225
|
detail: result.detail,
|
|
222
226
|
durationMs: result.durationMs,
|
package/src/translate/run.ts
CHANGED
|
@@ -58,8 +58,9 @@ export interface TranslateRunOptions {
|
|
|
58
58
|
* Called after each finished item to flush the ledger to disk, so an
|
|
59
59
|
* interrupted run keeps everything already translated. Calls are serialized
|
|
60
60
|
* here — concurrent workers finishing together never race the same file.
|
|
61
|
+
* The flush's result (`writeLedger`'s wrote-or-not boolean) is ignored.
|
|
61
62
|
*/
|
|
62
|
-
persistLedger?: () => Promise<
|
|
63
|
+
persistLedger?: () => Promise<boolean | undefined>;
|
|
63
64
|
project: BlumeProject;
|
|
64
65
|
/** The spawn function — injectable so tests never launch a real agent. */
|
|
65
66
|
run?: HeadlessRunner;
|
|
@@ -129,6 +130,8 @@ const runPageItem = async (
|
|
|
129
130
|
});
|
|
130
131
|
|
|
131
132
|
const sourceText = await readFile(item.sourcePath, "utf-8");
|
|
133
|
+
// SAFETY: `targets` maps every configured locale, and work items only carry
|
|
134
|
+
// configured locale codes.
|
|
132
135
|
const target = context.targets.get(item.locale) as LocaleConfig;
|
|
133
136
|
// A hand-authored translation can live at a non-canonical name (see
|
|
134
137
|
// WorkStatus); the disk probe finds only canonical targets, and a miss just
|
|
@@ -175,6 +178,8 @@ const runMetaItem = async (
|
|
|
175
178
|
const titles = Object.fromEntries(
|
|
176
179
|
item.entries.map((entry) => [metaDirKey(entry.meta.dir), entry.meta.title])
|
|
177
180
|
);
|
|
181
|
+
// SAFETY: `targets` maps every configured locale, and work items only carry
|
|
182
|
+
// configured locale codes.
|
|
178
183
|
const target = context.targets.get(item.locale) as LocaleConfig;
|
|
179
184
|
const output = await invokeAgent(
|
|
180
185
|
context,
|
|
@@ -257,6 +262,7 @@ export const runTranslate = async (
|
|
|
257
262
|
if (!i18n) {
|
|
258
263
|
throw new Error("blume translate requires i18n to be configured");
|
|
259
264
|
}
|
|
265
|
+
// SAFETY: the config schema requires `defaultLocale` to be one of `locales`.
|
|
260
266
|
const source = i18n.locales.find(
|
|
261
267
|
(locale) => locale.code === i18n.defaultLocale
|
|
262
268
|
) as LocaleConfig;
|
|
@@ -280,7 +286,7 @@ export const runTranslate = async (
|
|
|
280
286
|
// The persist mutex: ledger flushes from concurrent lanes are serialized so
|
|
281
287
|
// two lanes never write the ledger file at the same time.
|
|
282
288
|
const persistLimit = pLimit(1);
|
|
283
|
-
const persist = (): Promise<
|
|
289
|
+
const persist = (): Promise<boolean | undefined> =>
|
|
284
290
|
persistLimit(() => options.persistLedger?.());
|
|
285
291
|
|
|
286
292
|
const results = await pMap(
|
|
@@ -316,11 +322,11 @@ export const runTranslate = async (
|
|
|
316
322
|
}
|
|
317
323
|
}
|
|
318
324
|
|
|
319
|
-
const counts
|
|
325
|
+
const counts = {
|
|
320
326
|
failed: 0,
|
|
321
327
|
partial: 0,
|
|
322
328
|
translated: 0,
|
|
323
|
-
}
|
|
329
|
+
} satisfies Record<TranslateItemStatus, number>;
|
|
324
330
|
for (const result of results) {
|
|
325
331
|
counts[result.status] += 1;
|
|
326
332
|
}
|
|
@@ -13,6 +13,24 @@ export type ValidationResult =
|
|
|
13
13
|
| { ok: true; text: string }
|
|
14
14
|
| { ok: false; reason: string };
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* A parsed YAML frontmatter value (agent meta replies parse from JSON into the
|
|
18
|
+
* same shape). js-yaml can also mint Dates and other rich scalars; the
|
|
19
|
+
* traversal below only ever distinguishes "keyed object" from "string", so
|
|
20
|
+
* they ride along as the object arm.
|
|
21
|
+
*/
|
|
22
|
+
type FrontmatterValue =
|
|
23
|
+
| string
|
|
24
|
+
| number
|
|
25
|
+
| boolean
|
|
26
|
+
| null
|
|
27
|
+
| FrontmatterValue[]
|
|
28
|
+
| FrontmatterData;
|
|
29
|
+
|
|
30
|
+
interface FrontmatterData {
|
|
31
|
+
[key: string]: FrontmatterValue;
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
const FRONTMATTER_OPEN = /^---\r?\n/u;
|
|
17
35
|
const FENCE_LINE = /^\s*(?:```|~~~)/u;
|
|
18
36
|
|
|
@@ -33,27 +51,41 @@ export const stripOuterFence = (text: string): string => {
|
|
|
33
51
|
const countFenceLines = (text: string): number =>
|
|
34
52
|
text.split("\n").filter((line) => FENCE_LINE.test(line)).length;
|
|
35
53
|
|
|
36
|
-
const
|
|
37
|
-
|
|
54
|
+
const isKeyedObject = (
|
|
55
|
+
value: FrontmatterValue | undefined
|
|
56
|
+
): value is FrontmatterData => typeof value === "object" && value !== null;
|
|
57
|
+
|
|
58
|
+
const isString = (value: FrontmatterValue | undefined): value is string =>
|
|
59
|
+
typeof value === "string";
|
|
60
|
+
|
|
61
|
+
const getPath = (
|
|
62
|
+
data: FrontmatterValue,
|
|
63
|
+
path: readonly string[]
|
|
64
|
+
): FrontmatterValue | undefined => {
|
|
65
|
+
let value: FrontmatterValue | undefined = data;
|
|
38
66
|
for (const key of path) {
|
|
39
|
-
if (
|
|
67
|
+
if (!isKeyedObject(value)) {
|
|
40
68
|
return;
|
|
41
69
|
}
|
|
42
|
-
value =
|
|
70
|
+
value = value[key];
|
|
43
71
|
}
|
|
44
72
|
return value;
|
|
45
73
|
};
|
|
46
74
|
|
|
47
75
|
/** Set `path` on `data`; only called for paths whose parents exist in `data`. */
|
|
48
76
|
const setPath = (
|
|
49
|
-
data:
|
|
77
|
+
data: FrontmatterData,
|
|
50
78
|
path: readonly string[],
|
|
51
79
|
value: string
|
|
52
80
|
): void => {
|
|
53
81
|
let parent = data;
|
|
54
82
|
for (const key of path.slice(0, -1)) {
|
|
55
|
-
|
|
83
|
+
// SAFETY: callers only set paths that getPath already resolved to a string
|
|
84
|
+
// on this same (cloned) data, so every intermediate step is a keyed object.
|
|
85
|
+
parent = parent[key] as FrontmatterData;
|
|
56
86
|
}
|
|
87
|
+
// SAFETY: every TRANSLATABLE_KEY_PATHS entry is a non-empty tuple, so the
|
|
88
|
+
// path always has a final key.
|
|
57
89
|
parent[path.at(-1) as string] = value;
|
|
58
90
|
};
|
|
59
91
|
|
|
@@ -84,7 +116,7 @@ export const validateTranslation = (
|
|
|
84
116
|
};
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
let parsed: { content: string; data:
|
|
119
|
+
let parsed: { content: string; data: FrontmatterData };
|
|
88
120
|
try {
|
|
89
121
|
parsed = matter(candidate);
|
|
90
122
|
} catch {
|
|
@@ -114,13 +146,13 @@ export const validateTranslation = (
|
|
|
114
146
|
// Reconciliation by reconstruction: start from the SOURCE data and overlay
|
|
115
147
|
// only the translatable key paths where both sides hold a string and the
|
|
116
148
|
// translation is non-empty.
|
|
117
|
-
const data = structuredClone(source.data)
|
|
149
|
+
const data: FrontmatterData = structuredClone(source.data);
|
|
118
150
|
for (const path of TRANSLATABLE_KEY_PATHS) {
|
|
119
151
|
const original = getPath(source.data, path);
|
|
120
152
|
const translated = getPath(parsed.data, path);
|
|
121
153
|
if (
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
isString(original) &&
|
|
155
|
+
isString(translated) &&
|
|
124
156
|
translated.trim() !== ""
|
|
125
157
|
) {
|
|
126
158
|
setPath(data, path, translated);
|
|
@@ -133,6 +165,12 @@ export const validateTranslation = (
|
|
|
133
165
|
};
|
|
134
166
|
};
|
|
135
167
|
|
|
168
|
+
/** A meta-batch parse: recovered titles by key, plus the keys still missing. */
|
|
169
|
+
export interface MetaTitlesResult {
|
|
170
|
+
titles: Record<string, string>;
|
|
171
|
+
missing: string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
136
174
|
/**
|
|
137
175
|
* Extract translated sidebar titles from a meta reply: tolerant first-`{`
|
|
138
176
|
* to-last-`}` extraction (the eval `parseVerdict` idiom). Keys missing or
|
|
@@ -141,10 +179,10 @@ export const validateTranslation = (
|
|
|
141
179
|
export const parseMetaTitles = (
|
|
142
180
|
agentText: string,
|
|
143
181
|
expectedKeys: readonly string[]
|
|
144
|
-
):
|
|
182
|
+
): MetaTitlesResult => {
|
|
145
183
|
const start = agentText.indexOf("{");
|
|
146
184
|
const end = agentText.lastIndexOf("}");
|
|
147
|
-
let parsed:
|
|
185
|
+
let parsed: FrontmatterValue | undefined;
|
|
148
186
|
if (start !== -1 && end > start) {
|
|
149
187
|
try {
|
|
150
188
|
parsed = JSON.parse(agentText.slice(start, end + 1));
|
|
@@ -152,16 +190,13 @@ export const parseMetaTitles = (
|
|
|
152
190
|
parsed = undefined;
|
|
153
191
|
}
|
|
154
192
|
}
|
|
155
|
-
const record =
|
|
156
|
-
typeof parsed === "object" && parsed !== null
|
|
157
|
-
? (parsed as Record<string, unknown>)
|
|
158
|
-
: {};
|
|
193
|
+
const record: FrontmatterData = isKeyedObject(parsed) ? parsed : {};
|
|
159
194
|
|
|
160
195
|
const titles: Record<string, string> = {};
|
|
161
196
|
const missing: string[] = [];
|
|
162
197
|
for (const key of expectedKeys) {
|
|
163
198
|
const value = record[key];
|
|
164
|
-
if (
|
|
199
|
+
if (isString(value) && value.trim() !== "") {
|
|
165
200
|
titles[key] = value;
|
|
166
201
|
} else {
|
|
167
202
|
missing.push(key);
|
|
Binary file
|