blume 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/dist/cli/index.js +3512 -814
- package/dist/cli/index.js.map +99 -87
- 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 +15 -1
- 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 +22 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/component-markdown.ts +7 -6
- 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 +4 -13
- package/src/astro/integration.ts +3 -1
- package/src/astro/islands.ts +4 -1
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +69 -22
- package/src/audit/checks/indexability.ts +3 -6
- package/src/audit/checks/robots.ts +18 -37
- package/src/audit/crawl.ts +49 -49
- package/src/audit/image-size.ts +13 -53
- package/src/audit/report.ts +22 -33
- package/src/audit/types.ts +6 -2
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +129 -24
- package/src/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/env.ts +6 -52
- package/src/cli/index.ts +2 -0
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- 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/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- 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/RootLayout.astro +37 -109
- package/src/components/layout/Search.astro +18 -25
- package/src/components/layout/search/orama.ts +3 -1
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/base-path.ts +9 -0
- package/src/core/component-overrides.ts +0 -7
- package/src/core/config-input.ts +84 -6
- package/src/core/config.ts +3 -3
- package/src/core/diagnostics.ts +10 -20
- package/src/core/fs-atomic.ts +22 -0
- 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/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/notion.ts +17 -23
- package/src/core/sources/types.ts +5 -0
- package/src/core/tsconfig-aliases.ts +39 -172
- 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/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -1
- package/src/eval/agents.ts +1 -1
- package/src/eval/report.ts +20 -28
- package/src/markdown/directives.ts +6 -18
- package/src/markdown/index.ts +1 -6
- package/src/markdown/package-commands.ts +0 -4
- package/src/openapi/parse.ts +11 -9
- 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/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +142 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +354 -0
- package/src/translate/run.ts +357 -0
- package/src/translate/validate.ts +171 -0
- package/src/translate/work-list.ts +0 -0
- package/src/deploy/xml.ts +0 -8
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { sample } from "openapi-sampler";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Runtime helpers for the OpenAPI components. These operate on the parsed spec
|
|
3
5
|
* behind the `blume:openapi` alias — resolving `$ref`s (kept intact at parse
|
|
4
6
|
* time to avoid circular graphs), labelling types, and generating request
|
|
5
|
-
* examples and code samples.
|
|
6
|
-
*
|
|
7
|
+
* examples and code samples. Browser-safe (no server-only imports); example
|
|
8
|
+
* values come from openapi-sampler, which is likewise browser-safe.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
/** A permissive view of an OpenAPI 3.1 schema — only the fields we render. */
|
|
@@ -226,88 +228,32 @@ export const objectProperties = (
|
|
|
226
228
|
return { properties: [...properties.entries()], required };
|
|
227
229
|
};
|
|
228
230
|
|
|
229
|
-
/** Sentinel: no explicit example is declared on a schema. */
|
|
230
|
-
const NO_VALUE = Symbol("no-value");
|
|
231
|
-
|
|
232
|
-
/** The declared example/const/default/enum for a schema, or {@link NO_VALUE}. */
|
|
233
|
-
const explicitExample = (schema: SchemaLike): unknown => {
|
|
234
|
-
if (schema.example !== undefined) {
|
|
235
|
-
return schema.example;
|
|
236
|
-
}
|
|
237
|
-
if (Array.isArray(schema.examples) && schema.examples.length > 0) {
|
|
238
|
-
return schema.examples[0];
|
|
239
|
-
}
|
|
240
|
-
// `const` is the schema's only valid value (the 3.1 discriminator idiom), so
|
|
241
|
-
// it outranks `default`/`enum` — either of those differing would be invalid.
|
|
242
|
-
if (schema.const !== undefined) {
|
|
243
|
-
return schema.const;
|
|
244
|
-
}
|
|
245
|
-
if (schema.default !== undefined) {
|
|
246
|
-
return schema.default;
|
|
247
|
-
}
|
|
248
|
-
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
249
|
-
return schema.enum[0];
|
|
250
|
-
}
|
|
251
|
-
return NO_VALUE;
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
/** A placeholder value for a primitive (leaf) schema. */
|
|
255
|
-
const primitiveExample = (
|
|
256
|
-
types: string[],
|
|
257
|
-
format: string | undefined
|
|
258
|
-
): unknown => {
|
|
259
|
-
if (types.includes("number") || types.includes("integer")) {
|
|
260
|
-
return 0;
|
|
261
|
-
}
|
|
262
|
-
if (types.includes("boolean")) {
|
|
263
|
-
return true;
|
|
264
|
-
}
|
|
265
|
-
if (format === "date-time") {
|
|
266
|
-
return "2024-01-01T00:00:00Z";
|
|
267
|
-
}
|
|
268
|
-
return format ? `<${format}>` : "string";
|
|
269
|
-
};
|
|
270
|
-
|
|
271
231
|
/**
|
|
272
|
-
* Build a representative example value for a schema
|
|
273
|
-
*
|
|
274
|
-
*
|
|
232
|
+
* Build a representative example value for a schema via openapi-sampler
|
|
233
|
+
* (Redoc's generator): declared `example`/`const`/`default`/`enum` values
|
|
234
|
+
* win, formats produce realistic placeholders (`email`, `uuid`, `date-time`),
|
|
235
|
+
* `readOnly` fields are skipped (these samples illustrate *requests*, and a
|
|
236
|
+
* server-generated field has no place in one), and circular `$ref` chains —
|
|
237
|
+
* which keeping refs intact allows — terminate safely.
|
|
275
238
|
*/
|
|
276
239
|
export const exampleValue = (
|
|
277
240
|
schema: SchemaLike | undefined,
|
|
278
|
-
schemas: Record<string, SchemaLike
|
|
279
|
-
seen = new Set<string>()
|
|
241
|
+
schemas: Record<string, SchemaLike>
|
|
280
242
|
): unknown => {
|
|
281
243
|
if (!schema) {
|
|
282
244
|
return null;
|
|
283
245
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return
|
|
294
|
-
}
|
|
295
|
-
const branch = schema.oneOf?.[0] ?? schema.anyOf?.[0];
|
|
296
|
-
if (branch) {
|
|
297
|
-
return exampleValue(branch, schemas, seen);
|
|
298
|
-
}
|
|
299
|
-
const types = nonNullTypes(schema.type);
|
|
300
|
-
if (types.includes("array")) {
|
|
301
|
-
return [exampleValue(schema.items, schemas, seen)];
|
|
302
|
-
}
|
|
303
|
-
if (types.includes("object") || schema.properties || schema.allOf) {
|
|
304
|
-
const out: Record<string, unknown> = {};
|
|
305
|
-
for (const [name, prop] of objectProperties(schema, schemas).properties) {
|
|
306
|
-
out[name] = exampleValue(prop, schemas, new Set(seen));
|
|
307
|
-
}
|
|
308
|
-
return out;
|
|
246
|
+
try {
|
|
247
|
+
return sample(
|
|
248
|
+
schema as Parameters<typeof sample>[0],
|
|
249
|
+
{ quiet: true, skipReadOnly: true },
|
|
250
|
+
{ components: { schemas } }
|
|
251
|
+
);
|
|
252
|
+
} catch {
|
|
253
|
+
// An unresolvable $ref or malformed schema is a spec problem the schema
|
|
254
|
+
// tables already surface; a sample is best-effort.
|
|
255
|
+
return null;
|
|
309
256
|
}
|
|
310
|
-
return primitiveExample(types, schema.format);
|
|
311
257
|
};
|
|
312
258
|
|
|
313
259
|
/** Pretty-print a JSON value for an example/code block. */
|
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,
|
|
@@ -328,13 +328,6 @@ const finalize = (
|
|
|
328
328
|
);
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
if (client && !source) {
|
|
332
|
-
warnings.push(
|
|
333
|
-
`Override "${key}" declares client: "${client}" but its component couldn't be resolved to a file, so it can't hydrate. Reference it by an imported component or a path string.`
|
|
334
|
-
);
|
|
335
|
-
return { identifier, key, source: null };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
331
|
if (!client && source?.framework) {
|
|
339
332
|
warnings.push(
|
|
340
333
|
`Override "${key}" points to a ${FRAMEWORK_LABEL[source.framework]} component (${label}) but has no hydration mode, so it renders as static HTML with no interactivity. Add one, e.g. \`${key}: { component: ${JSON.stringify(label)}, client: "load" }\`.`
|
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/config.ts
CHANGED
|
@@ -219,14 +219,14 @@ export const loadConfig = async (
|
|
|
219
219
|
// Surface every issue in one failing run — reporting only the first turns
|
|
220
220
|
// a three-mistake config into three fix-rerun-fail loops.
|
|
221
221
|
const moreIssues = rest.map((d) => ` - ${d.message}`).join("\n");
|
|
222
|
-
|
|
222
|
+
const detail =
|
|
223
223
|
rest.length > 0
|
|
224
224
|
? {
|
|
225
225
|
...primary,
|
|
226
226
|
message: `${primary.message}\n${rest.length} more config issue(s):\n${moreIssues}`,
|
|
227
227
|
}
|
|
228
|
-
: primary
|
|
229
|
-
);
|
|
228
|
+
: primary;
|
|
229
|
+
throw new BlumeError(detail);
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
// Resolve the canonical site URL, then SEO defaults that depend on it.
|
package/src/core/diagnostics.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { colors } from "consola/utils";
|
|
1
2
|
import { relative } from "pathe";
|
|
2
3
|
import type { ZodError } from "zod";
|
|
3
4
|
|
|
@@ -196,25 +197,14 @@ export const diagnosticsFromZod = (
|
|
|
196
197
|
options
|
|
197
198
|
);
|
|
198
199
|
|
|
199
|
-
const
|
|
200
|
-
const COLORS = {
|
|
201
|
-
blue: `${ESC}[34m`,
|
|
202
|
-
bold: `${ESC}[1m`,
|
|
203
|
-
cyan: `${ESC}[36m`,
|
|
204
|
-
dim: `${ESC}[2m`,
|
|
205
|
-
red: `${ESC}[31m`,
|
|
206
|
-
reset: `${ESC}[0m`,
|
|
207
|
-
yellow: `${ESC}[33m`,
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const severityColor = (severity: Diagnostic["severity"]): string => {
|
|
200
|
+
const severityColor = (severity: Diagnostic["severity"]) => {
|
|
211
201
|
if (severity === "error") {
|
|
212
|
-
return
|
|
202
|
+
return colors.red;
|
|
213
203
|
}
|
|
214
204
|
if (severity === "warning") {
|
|
215
|
-
return
|
|
205
|
+
return colors.yellow;
|
|
216
206
|
}
|
|
217
|
-
return
|
|
207
|
+
return colors.blue;
|
|
218
208
|
};
|
|
219
209
|
|
|
220
210
|
/** Format a single diagnostic for terminal output. */
|
|
@@ -224,14 +214,14 @@ export const formatDiagnostic = (
|
|
|
224
214
|
): string => {
|
|
225
215
|
const color = severityColor(diagnostic.severity);
|
|
226
216
|
const lines: string[] = [
|
|
227
|
-
`${color
|
|
217
|
+
`${color(colors.bold(diagnostic.code))} ${diagnostic.message}`,
|
|
228
218
|
];
|
|
229
219
|
|
|
230
220
|
// An audit finding is about a built URL, and names the source file that fixes
|
|
231
221
|
// it as a second line ("at /docs/api" / "in docs/api.mdx:3:2"). Everything
|
|
232
222
|
// else is about a file alone, and keeps the original single `at file` line.
|
|
233
223
|
if (diagnostic.url) {
|
|
234
|
-
lines.push(` ${
|
|
224
|
+
lines.push(` ${colors.dim(`at ${diagnostic.url}`)}`);
|
|
235
225
|
}
|
|
236
226
|
if (diagnostic.file) {
|
|
237
227
|
const location = root ? relative(root, diagnostic.file) : diagnostic.file;
|
|
@@ -240,15 +230,15 @@ export const formatDiagnostic = (
|
|
|
240
230
|
const position =
|
|
241
231
|
diagnostic.line === undefined ? "" : `:${diagnostic.line}${column}`;
|
|
242
232
|
const label = diagnostic.url ? "in" : "at";
|
|
243
|
-
lines.push(` ${
|
|
233
|
+
lines.push(` ${colors.dim(`${label} ${location}${position}`)}`);
|
|
244
234
|
}
|
|
245
235
|
|
|
246
236
|
if (diagnostic.suggestion) {
|
|
247
|
-
lines.push(` ${
|
|
237
|
+
lines.push(` ${colors.cyan(`fix: ${diagnostic.suggestion}`)}`);
|
|
248
238
|
}
|
|
249
239
|
|
|
250
240
|
if (diagnostic.docsUrl) {
|
|
251
|
-
lines.push(` ${
|
|
241
|
+
lines.push(` ${colors.dim(`docs: ${diagnostic.docsUrl}`)}`);
|
|
252
242
|
}
|
|
253
243
|
|
|
254
244
|
return lines.join("\n");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { dirname } from "pathe";
|
|
4
|
+
import writeFileAtomic from "write-file-atomic";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Write text to `path` atomically (unique temp file + rename) after ensuring
|
|
8
|
+
* the parent directory exists, so a concurrent reader or file watcher never
|
|
9
|
+
* observes a missing or half-written file. write-file-atomic's temp names are
|
|
10
|
+
* unique per call — a pid-suffixed temp name is not, and two concurrent
|
|
11
|
+
* writers to the same target in one process (translate lanes, staged-content
|
|
12
|
+
* writes) would interleave through a shared temp file. `fsync` is off to
|
|
13
|
+
* match the previous behavior: the point is watcher atomicity, not crash
|
|
14
|
+
* durability, and a per-file fsync would slow dev regeneration.
|
|
15
|
+
*/
|
|
16
|
+
export const writeTextAtomic = async (
|
|
17
|
+
path: string,
|
|
18
|
+
text: string
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
await mkdir(dirname(path), { recursive: true });
|
|
21
|
+
await writeFileAtomic(path, text, { encoding: "utf-8", fsync: false });
|
|
22
|
+
};
|
package/src/core/graph.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { validateNavIcons, validateNavStructure } from "./nav-diagnostics.ts";
|
|
|
4
4
|
import { buildNavigation } from "./navigation.ts";
|
|
5
5
|
import type {
|
|
6
6
|
FolderMeta,
|
|
7
|
+
LocalizableLabel,
|
|
7
8
|
ResolvedConfig,
|
|
8
9
|
ResolvedI18nConfig,
|
|
9
10
|
} from "./schema.ts";
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
ContentGraph,
|
|
12
13
|
Diagnostic,
|
|
13
14
|
Navigation,
|
|
15
|
+
NavTab,
|
|
14
16
|
PageRecord,
|
|
15
17
|
} from "./types.ts";
|
|
16
18
|
|
|
@@ -79,6 +81,42 @@ const localePagesFor = (
|
|
|
79
81
|
return [...real, ...filled];
|
|
80
82
|
};
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a possibly-per-locale header label to the string a locale renders:
|
|
86
|
+
* the active locale's entry, else the default locale's, else the map's first
|
|
87
|
+
* entry (which is also what a single-locale site gets).
|
|
88
|
+
*/
|
|
89
|
+
const resolveLabel = (
|
|
90
|
+
label: LocalizableLabel,
|
|
91
|
+
locale: string,
|
|
92
|
+
defaultLocale?: string
|
|
93
|
+
): string => {
|
|
94
|
+
if (typeof label === "string") {
|
|
95
|
+
return label;
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
label[locale] ??
|
|
99
|
+
(defaultLocale === undefined ? undefined : label[defaultLocale]) ??
|
|
100
|
+
Object.values(label)[0] ??
|
|
101
|
+
""
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Resolve every localizable label in the configured tabs for one locale. */
|
|
106
|
+
const resolveTabLabels = (
|
|
107
|
+
tabs: BuildContentGraphOptions["navigation"]["tabs"],
|
|
108
|
+
locale: string,
|
|
109
|
+
defaultLocale?: string
|
|
110
|
+
): NavTab[] =>
|
|
111
|
+
(tabs ?? []).map((tab) => ({
|
|
112
|
+
...tab,
|
|
113
|
+
items: tab.items?.map((item) => ({
|
|
114
|
+
...item,
|
|
115
|
+
label: resolveLabel(item.label, locale, defaultLocale),
|
|
116
|
+
})),
|
|
117
|
+
label: resolveLabel(tab.label, locale, defaultLocale),
|
|
118
|
+
}));
|
|
119
|
+
|
|
82
120
|
/** Build one locale's navigation tree from its own pages and folder meta. */
|
|
83
121
|
const buildLocaleNavigation = (
|
|
84
122
|
code: string,
|
|
@@ -95,7 +133,11 @@ const buildLocaleNavigation = (
|
|
|
95
133
|
// selector's items intentionally target specific locales.
|
|
96
134
|
const localizePath = (path: string): string =>
|
|
97
135
|
path.startsWith("/") ? localizeRoute(path, code, i18n) : path;
|
|
98
|
-
const tabs =
|
|
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;
|