blume 1.4.2 → 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 +71 -0
- package/dist/cli/index.js +2260 -1100
- package/dist/cli/index.js.map +123 -117
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +87 -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 +116 -15
- package/dist/types/core/sources/types.d.ts +11 -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 +14 -7
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/ai.mdx +26 -8
- 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/sources.mdx +1 -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 +13 -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 +40 -12
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +49 -12
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +42 -17
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +52 -16
- package/src/ai/mcp/server.ts +280 -125
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/tar.ts +29 -70
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +13 -5
- package/src/astro/generate.ts +113 -63
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +23 -12
- package/src/astro/templates.ts +185 -41
- package/src/audit/agent.ts +16 -31
- 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 +78 -25
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +15 -5
- package/src/audit/snapshot.ts +29 -6
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +21 -21
- package/src/cli/commands/build.ts +30 -16
- package/src/cli/commands/dev.ts +15 -15
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +24 -30
- 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/env.ts +13 -30
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +41 -13
- package/src/cli/internal-error.ts +1 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- 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/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +111 -34
- 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/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +24 -48
- package/src/components/layout/Search.astro +133 -22
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/drawer-inert.ts +31 -0
- 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 +8 -5
- package/src/components/layout/search/types.ts +45 -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/panel.ts +11 -8
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +96 -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 +74 -4
- 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/probe.ts +7 -19
- package/src/core/project-graph.ts +27 -4
- package/src/core/schema.ts +219 -67
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +16 -8
- package/src/core/sources/github-releases.ts +39 -11
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +93 -22
- package/src/core/sources/notion.ts +76 -22
- package/src/core/sources/portable-text.ts +48 -12
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +17 -1
- 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 +40 -11
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +89 -8
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +5 -18
- 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 +63 -58
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +50 -33
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +135 -66
- package/src/openapi/parse.ts +166 -33
- package/src/openapi/references.ts +47 -22
- package/src/openapi/render-mdx.ts +137 -59
- package/src/openapi/scalar.ts +8 -10
- package/src/openapi/source.ts +126 -29
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +103 -39
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +117 -32
- 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 +27 -15
- package/src/translate/ledger.ts +4 -2
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +10 -19
- package/src/translate/run.ts +29 -38
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
- package/src/cli/coalesce.ts +0 -43
package/src/core/schema.ts
CHANGED
|
@@ -23,6 +23,14 @@ import type { StandardSchema } from "./standard-schema.ts";
|
|
|
23
23
|
// Shared primitives
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
|
|
26
|
+
// `typeof` checks live in named predicates (the form the oxlint anti-slop
|
|
27
|
+
// config sanctions); generic so each site keeps its own union narrowing.
|
|
28
|
+
const isString = <Value>(value: Value): value is Value & string =>
|
|
29
|
+
typeof value === "string";
|
|
30
|
+
|
|
31
|
+
const isBoolean = <Value>(value: Value): value is Value & boolean =>
|
|
32
|
+
typeof value === "boolean";
|
|
33
|
+
|
|
26
34
|
/** Icon inputs in serializable contexts (frontmatter, meta files). */
|
|
27
35
|
const iconName = z.string().min(1);
|
|
28
36
|
|
|
@@ -40,12 +48,28 @@ const dateSchema = z
|
|
|
40
48
|
.union([z.string(), z.date()])
|
|
41
49
|
.transform((value) => (value instanceof Date ? value.toISOString() : value));
|
|
42
50
|
|
|
51
|
+
/**
|
|
52
|
+
* How a sidebar group renders:
|
|
53
|
+
* - `flat`: a non-collapsible header with its items listed beneath (default).
|
|
54
|
+
* - `group`: a collapsible `<details>` disclosure.
|
|
55
|
+
* - `page`: a single row that drills into a sub-panel showing only this group's
|
|
56
|
+
* items, with a back arrow at the top.
|
|
57
|
+
*/
|
|
58
|
+
const sidebarDisplaySchema = z.enum(["flat", "group", "page"]);
|
|
59
|
+
export type SidebarDisplay = z.infer<typeof sidebarDisplaySchema>;
|
|
60
|
+
|
|
43
61
|
// ---------------------------------------------------------------------------
|
|
44
62
|
// Page frontmatter
|
|
45
63
|
// ---------------------------------------------------------------------------
|
|
46
64
|
|
|
47
65
|
const sidebarMetaSchema = z.strictObject({
|
|
48
66
|
badge: z.string().optional(),
|
|
67
|
+
/**
|
|
68
|
+
* Render mode for this page's folder group. Only meaningful on a folder's
|
|
69
|
+
* `index` page — it configures the group, not the page. Overrides the
|
|
70
|
+
* folder's `meta.ts` `display` and the global `navigation.sidebar.display`.
|
|
71
|
+
*/
|
|
72
|
+
display: sidebarDisplaySchema.optional(),
|
|
49
73
|
hidden: z.boolean().default(false),
|
|
50
74
|
icon: iconName.optional(),
|
|
51
75
|
label: z.string().optional(),
|
|
@@ -141,6 +165,11 @@ export const pageMetaSchema = pageMetaBaseSchema;
|
|
|
141
165
|
export type PageMeta = z.infer<typeof pageMetaBaseSchema>;
|
|
142
166
|
export type PageMetaInput = z.input<typeof pageMetaBaseSchema>;
|
|
143
167
|
|
|
168
|
+
/** Built-in page frontmatter keys; custom keys must never redeclare one. */
|
|
169
|
+
const BUILT_IN_PAGE_META_KEYS = new Set<string>(
|
|
170
|
+
pageMetaBaseSchema.keyof().options
|
|
171
|
+
);
|
|
172
|
+
|
|
144
173
|
/**
|
|
145
174
|
* A map of custom frontmatter keys to user-supplied validation schemas,
|
|
146
175
|
* consumed through the Standard Schema `~standard` contract — never Zod's own
|
|
@@ -162,7 +191,7 @@ const customKeySchemaRecord = (where: string) =>
|
|
|
162
191
|
.default({})
|
|
163
192
|
.superRefine((value, ctx) => {
|
|
164
193
|
for (const key of Object.keys(value)) {
|
|
165
|
-
if (
|
|
194
|
+
if (BUILT_IN_PAGE_META_KEYS.has(key)) {
|
|
166
195
|
ctx.addIssue({
|
|
167
196
|
code: z.ZodIssueCode.custom,
|
|
168
197
|
message: `"${key}" is a built-in frontmatter field and cannot be redeclared via ${where}.`,
|
|
@@ -176,18 +205,10 @@ const customKeySchemaRecord = (where: string) =>
|
|
|
176
205
|
// Folder meta (meta.ts)
|
|
177
206
|
// ---------------------------------------------------------------------------
|
|
178
207
|
|
|
179
|
-
/**
|
|
180
|
-
* How a sidebar group renders:
|
|
181
|
-
* - `flat`: a non-collapsible header with its items listed beneath (default).
|
|
182
|
-
* - `group`: a collapsible `<details>` disclosure.
|
|
183
|
-
* - `page`: a single row that drills into a sub-panel showing only this group's
|
|
184
|
-
* items, with a back arrow at the top.
|
|
185
|
-
*/
|
|
186
|
-
const sidebarDisplaySchema = z.enum(["flat", "group", "page"]);
|
|
187
|
-
export type SidebarDisplay = z.infer<typeof sidebarDisplaySchema>;
|
|
188
|
-
|
|
189
208
|
export const folderMetaSchema = z.strictObject({
|
|
190
209
|
collapsed: z.boolean().optional(),
|
|
210
|
+
/** Render mode for this group; overrides `navigation.sidebar.display`. */
|
|
211
|
+
display: sidebarDisplaySchema.optional(),
|
|
191
212
|
icon: iconName.optional(),
|
|
192
213
|
order: z.number().optional(),
|
|
193
214
|
/** Explicit child ordering by slug segment (without numeric prefix). */
|
|
@@ -304,6 +325,8 @@ const sanitySourceSchema = z.object({
|
|
|
304
325
|
|
|
305
326
|
/** A Notion database; pages become entries, blocks become MDX. */
|
|
306
327
|
const notionSourceSchema = z.object({
|
|
328
|
+
/** Max concurrent Notion API requests; default 3 (Notion's per-integration pace). */
|
|
329
|
+
concurrency: z.number().positive().optional(),
|
|
307
330
|
database: z.string(),
|
|
308
331
|
/** Opt-in dev polling interval (seconds); omit to freeze for the session. */
|
|
309
332
|
pollInterval: z.number().positive().optional(),
|
|
@@ -353,11 +376,13 @@ const githubReleasesSourceSchema = z.strictObject({
|
|
|
353
376
|
*/
|
|
354
377
|
const customSourceSchema = z.object({
|
|
355
378
|
source: z.custom<ContentSource>(
|
|
356
|
-
(val) =>
|
|
379
|
+
(val): val is ContentSource =>
|
|
357
380
|
typeof val === "object" &&
|
|
358
381
|
val !== null &&
|
|
359
|
-
|
|
360
|
-
typeof
|
|
382
|
+
"load" in val &&
|
|
383
|
+
typeof val.load === "function" &&
|
|
384
|
+
"name" in val &&
|
|
385
|
+
typeof val.name === "string",
|
|
361
386
|
{ message: "custom source must be a ContentSource (with name + load)" }
|
|
362
387
|
),
|
|
363
388
|
type: z.literal("custom"),
|
|
@@ -556,7 +581,7 @@ const localFontSchema = z.strictObject({
|
|
|
556
581
|
const fontValueSchema = z
|
|
557
582
|
.union([z.string(), remoteFontSchema, localFontSchema])
|
|
558
583
|
.superRefine((value, ctx) => {
|
|
559
|
-
if (
|
|
584
|
+
if (isString(value) && !isFontSlug(value)) {
|
|
560
585
|
ctx.addIssue({
|
|
561
586
|
code: z.ZodIssueCode.custom,
|
|
562
587
|
message: `Unknown font "${value}". Supported fonts: ${FONT_SLUGS.join(", ")}. For any other family, use the object form: { name: "..." } (remote provider) or { name: "...", variants: [...] } (local files).`,
|
|
@@ -579,7 +604,7 @@ const perModeValueSchema = z
|
|
|
579
604
|
])
|
|
580
605
|
.optional()
|
|
581
606
|
.transform((value) =>
|
|
582
|
-
|
|
607
|
+
isString(value) ? { dark: value, light: value } : value
|
|
583
608
|
);
|
|
584
609
|
|
|
585
610
|
const themeConfigSchema = z.strictObject({
|
|
@@ -590,7 +615,7 @@ const themeConfigSchema = z.strictObject({
|
|
|
590
615
|
])
|
|
591
616
|
.default("blue")
|
|
592
617
|
.transform((value) =>
|
|
593
|
-
|
|
618
|
+
isString(value) ? { dark: value, light: value } : value
|
|
594
619
|
),
|
|
595
620
|
action: z.string().optional(),
|
|
596
621
|
background: perModeValueSchema,
|
|
@@ -680,6 +705,8 @@ const searchConfigSchema = z
|
|
|
680
705
|
.superRefine((value, ctx) => {
|
|
681
706
|
// Hosted providers can't work without their credentials; flag a missing
|
|
682
707
|
// block with a path so the diagnostic points at `search.<provider>`.
|
|
708
|
+
// SAFETY: providers without a config block (orama, pagefind, …) miss the
|
|
709
|
+
// map and read undefined, which the `field &&` guard below absorbs.
|
|
683
710
|
const field =
|
|
684
711
|
PROVIDER_CONFIG_KEY[value.provider as keyof typeof PROVIDER_CONFIG_KEY];
|
|
685
712
|
if (field && !value[field]) {
|
|
@@ -716,7 +743,7 @@ const PRIVATE_JWK_PARAMS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
|
|
|
716
743
|
const publicJwkSchema = z
|
|
717
744
|
.record(z.string(), z.unknown())
|
|
718
745
|
.superRefine((jwk, ctx) => {
|
|
719
|
-
if (
|
|
746
|
+
if (!isString(jwk.kty) || jwk.kty.length === 0) {
|
|
720
747
|
ctx.addIssue({
|
|
721
748
|
code: z.ZodIssueCode.custom,
|
|
722
749
|
message: 'A JWK must declare its key type ("kty").',
|
|
@@ -780,6 +807,10 @@ const aiConfigSchema = z.strictObject({
|
|
|
780
807
|
// and host Ask AI in an existing backend. Absolute URLs and root-relative
|
|
781
808
|
// paths are both valid; the built-in request/stream contract is unchanged.
|
|
782
809
|
endpoint: askEndpointSchema.optional(),
|
|
810
|
+
// Extra system-prompt text (identity, language, tone) appended to the
|
|
811
|
+
// built-in instructions, so the grounding contract — answer from the
|
|
812
|
+
// retrieved excerpts, cite pages as Markdown links — stays intact.
|
|
813
|
+
instructions: z.string().trim().min(1).optional(),
|
|
783
814
|
model: z.string().default("openai/gpt-5.5"),
|
|
784
815
|
provider: z.enum(askAiProviders).default("gateway"),
|
|
785
816
|
// Empty-state prompts shown before the first question. Each renders as a
|
|
@@ -824,7 +855,7 @@ const aiConfigSchema = z.strictObject({
|
|
|
824
855
|
])
|
|
825
856
|
.default(true)
|
|
826
857
|
.transform((value) =>
|
|
827
|
-
|
|
858
|
+
isBoolean(value) ? { enabled: value, openapi: true } : value
|
|
828
859
|
),
|
|
829
860
|
// Serializers for the agent-facing Markdown downlevel (the `.md` mirror,
|
|
830
861
|
// llms-full.txt, MCP get_page), keyed by JSX name. Functions live here —
|
|
@@ -836,9 +867,12 @@ const aiConfigSchema = z.strictObject({
|
|
|
836
867
|
markdownComponents: z
|
|
837
868
|
.record(
|
|
838
869
|
z.string(),
|
|
839
|
-
z.custom<ComponentMarkdown>(
|
|
840
|
-
|
|
841
|
-
|
|
870
|
+
z.custom<ComponentMarkdown>(
|
|
871
|
+
(value): value is ComponentMarkdown => typeof value === "function",
|
|
872
|
+
{
|
|
873
|
+
message: "Expected a serializer function.",
|
|
874
|
+
}
|
|
875
|
+
)
|
|
842
876
|
)
|
|
843
877
|
.default({}),
|
|
844
878
|
/** Expose the docs as an MCP server for connecting agents. */
|
|
@@ -927,7 +961,7 @@ const exportConfigSchema = z
|
|
|
927
961
|
}),
|
|
928
962
|
])
|
|
929
963
|
.transform((value) =>
|
|
930
|
-
|
|
964
|
+
isBoolean(value) ? { epub: value, pdf: value } : value
|
|
931
965
|
);
|
|
932
966
|
|
|
933
967
|
/** A configured locale: ISO-ish code plus display metadata for the switcher. */
|
|
@@ -984,6 +1018,82 @@ const i18nConfigSchema = z
|
|
|
984
1018
|
}
|
|
985
1019
|
});
|
|
986
1020
|
|
|
1021
|
+
/**
|
|
1022
|
+
* Version ids must start with a letter (`v1.0`, not `1.0`): the id doubles as
|
|
1023
|
+
* the snapshot directory name, and a leading digit would collide with the
|
|
1024
|
+
* numeric-prefix ordering convention (`01-intro.mdx`), which strips `1.0/` to
|
|
1025
|
+
* `0/`. The rest allows word characters, dots, and hyphens — URL-safe as-is.
|
|
1026
|
+
*/
|
|
1027
|
+
export const VERSION_ID = /^[A-Za-z][\w.-]*$/u;
|
|
1028
|
+
|
|
1029
|
+
/** A frozen documentation snapshot: a directory under the content root. */
|
|
1030
|
+
const archivedVersionSchema = z.strictObject({
|
|
1031
|
+
/**
|
|
1032
|
+
* The "you're viewing an old version" notice: `true` for the built-in
|
|
1033
|
+
* message, a string for custom copy, `false` to hide it.
|
|
1034
|
+
*/
|
|
1035
|
+
banner: z.union([z.boolean(), z.string()]).default(true),
|
|
1036
|
+
/**
|
|
1037
|
+
* Where this version's pages point their canonical URL: `latest` targets the
|
|
1038
|
+
* same page in the current docs when it still exists (self otherwise), so
|
|
1039
|
+
* search engines treat the live page as authoritative without deindexing
|
|
1040
|
+
* version-only content. `self` keeps every page authoritative.
|
|
1041
|
+
*/
|
|
1042
|
+
canonical: z.enum(["latest", "self"]).default("latest"),
|
|
1043
|
+
/** Directory name under the content root, and the URL segment. */
|
|
1044
|
+
id: z
|
|
1045
|
+
.string()
|
|
1046
|
+
.regex(
|
|
1047
|
+
VERSION_ID,
|
|
1048
|
+
'Version ids must start with a letter (e.g. "v1.0") and contain only letters, digits, dots, hyphens, and underscores.'
|
|
1049
|
+
),
|
|
1050
|
+
/** Switcher label; defaults to the id. */
|
|
1051
|
+
label: z.string().optional(),
|
|
1052
|
+
/** Emit `noindex` on every page of this version. */
|
|
1053
|
+
noindex: z.boolean().default(false),
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Docs versioning. Opt-in: the latest docs live at the content root with
|
|
1058
|
+
* unprefixed URLs, and each archived version is a frozen snapshot directory
|
|
1059
|
+
* (`content/docs/<id>/`) cut with `blume version <id>`. Archived means frozen:
|
|
1060
|
+
* snapshots carry their own translations and are never retranslated.
|
|
1061
|
+
*/
|
|
1062
|
+
const versionsConfigSchema = z
|
|
1063
|
+
.strictObject({
|
|
1064
|
+
/** Frozen snapshots, newest first — this order is the switcher order. */
|
|
1065
|
+
archived: z.array(archivedVersionSchema).default([]),
|
|
1066
|
+
/** Labels the unprefixed tree (the latest docs) in the switcher. */
|
|
1067
|
+
current: z.strictObject({
|
|
1068
|
+
/** Small tag rendered next to the label (e.g. `Latest`). */
|
|
1069
|
+
badge: z.string().optional(),
|
|
1070
|
+
label: z.string(),
|
|
1071
|
+
}),
|
|
1072
|
+
switcher: z
|
|
1073
|
+
.strictObject({
|
|
1074
|
+
/**
|
|
1075
|
+
* Where switching lands when the page has no equivalent in the target
|
|
1076
|
+
* version: `same-page` goes to the equivalent when it exists (version
|
|
1077
|
+
* root otherwise); `root` always goes to the version root.
|
|
1078
|
+
*/
|
|
1079
|
+
redirect: z.enum(["same-page", "root"]).default("same-page"),
|
|
1080
|
+
})
|
|
1081
|
+
.prefault({}),
|
|
1082
|
+
})
|
|
1083
|
+
.superRefine((value, ctx) => {
|
|
1084
|
+
const seen = new Set<string>();
|
|
1085
|
+
for (const [position, version] of value.archived.entries()) {
|
|
1086
|
+
if (seen.has(version.id)) {
|
|
1087
|
+
ctx.addIssue({
|
|
1088
|
+
code: z.ZodIssueCode.custom,
|
|
1089
|
+
message: `versions.archived declares "${version.id}" more than once.`,
|
|
1090
|
+
path: ["archived", position, "id"],
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
seen.add(version.id);
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
|
|
987
1097
|
const analyticsScriptSchema = z
|
|
988
1098
|
.strictObject({
|
|
989
1099
|
// Extra attributes (e.g. `data-domain`, `id`) spread onto the <script>.
|
|
@@ -1228,11 +1338,22 @@ const githubConfigSchema = z.strictObject({
|
|
|
1228
1338
|
repo: z.string(),
|
|
1229
1339
|
});
|
|
1230
1340
|
|
|
1341
|
+
/** The theme fields the structural code-theme check inspects. */
|
|
1342
|
+
interface CodeThemeFields {
|
|
1343
|
+
colors?: unknown;
|
|
1344
|
+
settings?: unknown;
|
|
1345
|
+
tokenColors?: unknown;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** A non-null, non-array object — the floor for a theme and its `colors` map. */
|
|
1349
|
+
const isThemeObject = <Value>(value: Value): value is Value & CodeThemeFields =>
|
|
1350
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1351
|
+
|
|
1231
1352
|
const codeThemeSchema = z.custom<CodeTheme>((value) => {
|
|
1232
|
-
if (
|
|
1353
|
+
if (isString(value)) {
|
|
1233
1354
|
return true;
|
|
1234
1355
|
}
|
|
1235
|
-
if (
|
|
1356
|
+
if (!isThemeObject(value)) {
|
|
1236
1357
|
return false;
|
|
1237
1358
|
}
|
|
1238
1359
|
// Token rules live in `settings` (Shiki's canonical field, also the TextMate
|
|
@@ -1240,20 +1361,15 @@ const codeThemeSchema = z.custom<CodeTheme>((value) => {
|
|
|
1240
1361
|
// VS Code spelling Shiki falls back to). A colors-only theme (editor fg/bg,
|
|
1241
1362
|
// no token rules) is also valid — Shiki renders it from `colors` alone. Each
|
|
1242
1363
|
// field present must have the right shape, and at least one must be present.
|
|
1243
|
-
const theme = value as Record<string, unknown>;
|
|
1244
1364
|
const settingsValid =
|
|
1245
|
-
|
|
1365
|
+
value.settings === undefined || Array.isArray(value.settings);
|
|
1246
1366
|
const tokenColorsValid =
|
|
1247
|
-
|
|
1248
|
-
const colorsValid =
|
|
1249
|
-
theme.colors === undefined ||
|
|
1250
|
-
(typeof theme.colors === "object" &&
|
|
1251
|
-
theme.colors !== null &&
|
|
1252
|
-
!Array.isArray(theme.colors));
|
|
1367
|
+
value.tokenColors === undefined || Array.isArray(value.tokenColors);
|
|
1368
|
+
const colorsValid = value.colors === undefined || isThemeObject(value.colors);
|
|
1253
1369
|
const hasContent =
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1370
|
+
value.settings !== undefined ||
|
|
1371
|
+
value.tokenColors !== undefined ||
|
|
1372
|
+
value.colors !== undefined;
|
|
1257
1373
|
return settingsValid && tokenColorsValid && colorsValid && hasContent;
|
|
1258
1374
|
}, "Expected a Shiki theme name or custom theme object");
|
|
1259
1375
|
|
|
@@ -1292,7 +1408,7 @@ const examplesConfigSchema = z
|
|
|
1292
1408
|
}),
|
|
1293
1409
|
])
|
|
1294
1410
|
.transform((value): { css?: string; source: string } =>
|
|
1295
|
-
|
|
1411
|
+
isString(value) ? { source: value } : value
|
|
1296
1412
|
);
|
|
1297
1413
|
|
|
1298
1414
|
/**
|
|
@@ -1392,7 +1508,8 @@ const reactConfigSchema = z.strictObject({
|
|
|
1392
1508
|
|
|
1393
1509
|
/**
|
|
1394
1510
|
* A single spec rendered by the API reference. `spec` is a local path or an
|
|
1395
|
-
* `http(s)` URL (OpenAPI
|
|
1511
|
+
* `http(s)` URL (an OpenAPI document under `openapi`, an AsyncAPI document
|
|
1512
|
+
* under `asyncapi`).
|
|
1396
1513
|
*/
|
|
1397
1514
|
const openapiSourceSchema = z.strictObject({
|
|
1398
1515
|
/** Include generated pages from this spec in llms.txt/llms-full.txt. */
|
|
@@ -1421,6 +1538,35 @@ export type OpenApiSource = z.input<typeof openapiSourceSchema>;
|
|
|
1421
1538
|
*/
|
|
1422
1539
|
const scalarConfigSchema = z.record(z.string(), z.unknown()).optional();
|
|
1423
1540
|
|
|
1541
|
+
/**
|
|
1542
|
+
* The shared shape of both API-reference blocks — only the mount route and
|
|
1543
|
+
* code-sample defaults differ per spec kind, so each block declares just
|
|
1544
|
+
* those.
|
|
1545
|
+
*/
|
|
1546
|
+
const referenceConfigSchema = (defaults: {
|
|
1547
|
+
codeSamples: string[];
|
|
1548
|
+
route: string;
|
|
1549
|
+
}) =>
|
|
1550
|
+
z.strictObject({
|
|
1551
|
+
/** Code-sample languages/tools shown per operation (Blume renderer). */
|
|
1552
|
+
codeSamples: z.array(z.string()).default(defaults.codeSamples),
|
|
1553
|
+
enabled: z.boolean().default(false),
|
|
1554
|
+
/** Start nested schema rows expanded rather than collapsed (Blume renderer). */
|
|
1555
|
+
expandSchemas: z.boolean().default(false),
|
|
1556
|
+
/** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
|
|
1557
|
+
renderer: z.enum(["blume", "scalar"]).default("blume"),
|
|
1558
|
+
/** Where the reference mounts. */
|
|
1559
|
+
route: z.string().default(defaults.route),
|
|
1560
|
+
/** Extra Scalar config forwarded to `<ScalarComponent>` (Scalar renderer only). */
|
|
1561
|
+
scalar: scalarConfigSchema,
|
|
1562
|
+
/** One or more specs; each renders on its own route by default. */
|
|
1563
|
+
sources: z.array(openapiSourceSchema).default([]),
|
|
1564
|
+
/** Shorthand for a single source: `sources: [{ spec }]`. */
|
|
1565
|
+
spec: z.string().optional(),
|
|
1566
|
+
/** Scalar theme name (Scalar renderer only). */
|
|
1567
|
+
theme: z.string().optional(),
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1424
1570
|
/**
|
|
1425
1571
|
* OpenAPI reference. By default (`renderer: "blume"`) Blume parses the spec with
|
|
1426
1572
|
* Scalar's parser and renders its own UI: one real page per operation, grouped
|
|
@@ -1428,38 +1574,22 @@ const scalarConfigSchema = z.record(z.string(), z.unknown()).optional();
|
|
|
1428
1574
|
* `renderer: "scalar"` to fall back to the embedded Scalar SPA (a single
|
|
1429
1575
|
* self-contained route that doesn't weave into the sidebar or search).
|
|
1430
1576
|
*/
|
|
1431
|
-
const openapiConfigSchema =
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
enabled: z.boolean().default(false),
|
|
1435
|
-
/** Start nested schema rows expanded rather than collapsed (Blume renderer). */
|
|
1436
|
-
expandSchemas: z.boolean().default(false),
|
|
1437
|
-
/** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
|
|
1438
|
-
renderer: z.enum(["blume", "scalar"]).default("blume"),
|
|
1439
|
-
/** Where the reference mounts. */
|
|
1440
|
-
route: z.string().default("/reference"),
|
|
1441
|
-
/** Extra Scalar config forwarded to `<ScalarComponent>` (Scalar renderer only). */
|
|
1442
|
-
scalar: scalarConfigSchema,
|
|
1443
|
-
/** One or more specs; each renders on its own route by default. */
|
|
1444
|
-
sources: z.array(openapiSourceSchema).default([]),
|
|
1445
|
-
/** Shorthand for a single source: `sources: [{ spec }]`. */
|
|
1446
|
-
spec: z.string().optional(),
|
|
1447
|
-
/** Scalar theme name (Scalar renderer only). */
|
|
1448
|
-
theme: z.string().optional(),
|
|
1577
|
+
const openapiConfigSchema = referenceConfigSchema({
|
|
1578
|
+
codeSamples: ["curl", "js", "python"],
|
|
1579
|
+
route: "/reference",
|
|
1449
1580
|
});
|
|
1450
1581
|
|
|
1451
1582
|
/**
|
|
1452
|
-
* AsyncAPI reference. Same shape
|
|
1453
|
-
* (
|
|
1583
|
+
* AsyncAPI reference. Same shape as {@link openapiConfigSchema}: by default
|
|
1584
|
+
* (`renderer: "blume"`) Blume normalizes the spec to AsyncAPI 3.x and renders
|
|
1585
|
+
* its own UI — one real page per operation — with `renderer: "scalar"` as the
|
|
1586
|
+
* embedded-SPA opt-out. Only the defaults differ: the reference mounts at
|
|
1587
|
+
* `/events`, and empty `codeSamples` means every tool the operation's protocol
|
|
1588
|
+
* binding suggests.
|
|
1454
1589
|
*/
|
|
1455
|
-
const asyncapiConfigSchema =
|
|
1456
|
-
|
|
1457
|
-
route:
|
|
1458
|
-
/** Extra Scalar config forwarded to `<ScalarComponent>`. */
|
|
1459
|
-
scalar: scalarConfigSchema,
|
|
1460
|
-
sources: z.array(openapiSourceSchema).default([]),
|
|
1461
|
-
spec: z.string().optional(),
|
|
1462
|
-
theme: z.string().optional(),
|
|
1590
|
+
const asyncapiConfigSchema = referenceConfigSchema({
|
|
1591
|
+
codeSamples: [],
|
|
1592
|
+
route: "/events",
|
|
1463
1593
|
});
|
|
1464
1594
|
|
|
1465
1595
|
/**
|
|
@@ -1494,7 +1624,7 @@ const tocConfigSchema = z
|
|
|
1494
1624
|
])
|
|
1495
1625
|
.default(true)
|
|
1496
1626
|
.transform((value) => {
|
|
1497
|
-
if (
|
|
1627
|
+
if (isBoolean(value)) {
|
|
1498
1628
|
return { enabled: value, maxLevel: 3, minLevel: 2 };
|
|
1499
1629
|
}
|
|
1500
1630
|
return {
|
|
@@ -1562,8 +1692,26 @@ export const blumeConfigSchema = z
|
|
|
1562
1692
|
theme: themeConfigSchema.prefault({}),
|
|
1563
1693
|
title: z.string().default("Documentation"),
|
|
1564
1694
|
toc: tocConfigSchema,
|
|
1695
|
+
versions: versionsConfigSchema.optional(),
|
|
1565
1696
|
})
|
|
1566
1697
|
.superRefine((config, ctx) => {
|
|
1698
|
+
// A version id that is also a configured locale code would make a leading
|
|
1699
|
+
// `<id>/` directory ambiguous between the two axes — refuse it outright so
|
|
1700
|
+
// detection order (version first, then locale) never has to guess.
|
|
1701
|
+
if (config.versions && config.i18n) {
|
|
1702
|
+
const localeCodes = new Set(
|
|
1703
|
+
config.i18n.locales.map((locale) => locale.code.toLowerCase())
|
|
1704
|
+
);
|
|
1705
|
+
for (const [position, version] of config.versions.archived.entries()) {
|
|
1706
|
+
if (localeCodes.has(version.id.toLowerCase())) {
|
|
1707
|
+
ctx.addIssue({
|
|
1708
|
+
code: z.ZodIssueCode.custom,
|
|
1709
|
+
message: `Version id "${version.id}" is also a configured locale code — rename the version (e.g. "v${version.id}").`,
|
|
1710
|
+
path: ["versions", "archived", position, "id"],
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1567
1715
|
// A custom key is declared site-wide (`frontmatter.extend`) or per-type
|
|
1568
1716
|
// (`content.types`), never both — two schemas for one key would make
|
|
1569
1717
|
// precedence on pages of that type ambiguous.
|
|
@@ -1607,6 +1755,10 @@ export type FrontmatterExtend = Record<string, StandardSchema>;
|
|
|
1607
1755
|
export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
|
|
1608
1756
|
/** A configured locale with display metadata. */
|
|
1609
1757
|
export type LocaleConfig = z.infer<typeof localeSchema>;
|
|
1758
|
+
/** Resolved versions block (present only when the project opts into versioning). */
|
|
1759
|
+
export type ResolvedVersionsConfig = z.infer<typeof versionsConfigSchema>;
|
|
1760
|
+
/** A configured archived (frozen) version. */
|
|
1761
|
+
export type ArchivedVersionConfig = z.infer<typeof archivedVersionSchema>;
|
|
1610
1762
|
/**
|
|
1611
1763
|
* User-authored config, straight off the schema. The public, hand-documented
|
|
1612
1764
|
* authoring type is `BlumeConfig` in `./config-input.ts`, which a compile-time
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { joinURL } from "ufo";
|
|
2
|
+
|
|
3
|
+
import { trimEnd } from "./trim.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Absolute-URL building for the configured `deployment.site`, shared by every
|
|
7
|
+
* emitter that prints site URLs (sitemap, RSS, robots, llms.txt, the MCP and
|
|
8
|
+
* agent-discovery documents). One implementation replaces eight per-file
|
|
9
|
+
* copies that had drifted across three different trailing-slash treatments.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately not `new URL(path, site)`: a root-absolute path would drop the
|
|
12
|
+
* base path of a subpath deployment (`acme.com/docs`). ufo's `joinURL` joins
|
|
13
|
+
* without that footgun; the site is first trimmed with the ReDoS-safe
|
|
14
|
+
* `trimEnd` loop so even a malformed `site` with piled-up trailing slashes
|
|
15
|
+
* joins cleanly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The configured site with any trailing slashes dropped. */
|
|
19
|
+
export const siteRoot = (site: string): string => trimEnd(site, "/");
|
|
20
|
+
|
|
21
|
+
/** `site` + root-absolute `path` (already carrying any deployment base). */
|
|
22
|
+
export const absoluteUrl = (site: string, path: string): string => {
|
|
23
|
+
const root = siteRoot(site);
|
|
24
|
+
// joinURL folds a lone "/" away entirely; the homepage keeps its slash
|
|
25
|
+
// (`https://example.com/`), matching what every emitter always printed.
|
|
26
|
+
return path === "/" ? `${root}/` : joinURL(root, path);
|
|
27
|
+
};
|
|
@@ -78,6 +78,8 @@ export const materializeAssets = async (
|
|
|
78
78
|
await writeFile(join(ctx.assetsDir, file), bytes);
|
|
79
79
|
rewrites.set(url, `${ctx.assetsBaseUrl}/${file}`);
|
|
80
80
|
} catch (error) {
|
|
81
|
+
// SAFETY: everything thrown in this block is an Error — the manual
|
|
82
|
+
// `!res.ok` throw above, and fetch/fs failures.
|
|
81
83
|
diagnostics.push({
|
|
82
84
|
code: "BLUME_ASSET_FETCH_FAILED",
|
|
83
85
|
message: `Failed to download asset ${url}: ${(error as Error).message}`,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
|
|
3
4
|
import { join } from "pathe";
|
|
@@ -6,14 +7,15 @@ import { BlumeError } from "../diagnostics.ts";
|
|
|
6
7
|
import type { Diagnostic } from "../types.ts";
|
|
7
8
|
import type { SourceEntry, SourceLoadResult } from "./types.ts";
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Small, stable content hash for cache/HMR bookkeeping — and for staged asset
|
|
12
|
+
* *filenames* (see sources/assets.ts and content-assets.ts), where a collision
|
|
13
|
+
* silently serves the wrong file. 64 bits of SHA-256 keeps those names
|
|
14
|
+
* collision-safe at any realistic asset count; the old 31-bit DJB2 hash had a
|
|
15
|
+
* ~46k-item birthday bound.
|
|
16
|
+
*/
|
|
17
|
+
export const hashText = (text: string): string =>
|
|
18
|
+
createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
17
19
|
|
|
18
20
|
/** A stable digest of a source's entries, for change detection while polling. */
|
|
19
21
|
export const entriesDigest = (entries: SourceEntry[]): string =>
|
|
@@ -83,6 +85,9 @@ export const snapshotCache = (cacheDir: string): SnapshotCache => {
|
|
|
83
85
|
return {
|
|
84
86
|
read: async () => {
|
|
85
87
|
try {
|
|
88
|
+
// SAFETY: the snapshot file is only ever written by `write` below, from
|
|
89
|
+
// a `SourceEntry[]` via JSON.stringify; a corrupt file lands in the
|
|
90
|
+
// catch and reads as empty.
|
|
86
91
|
return JSON.parse(await readFile(file, "utf-8")) as SourceEntry[];
|
|
87
92
|
} catch {
|
|
88
93
|
return [];
|
|
@@ -124,6 +129,8 @@ export const loadWithCache = async (
|
|
|
124
129
|
} catch (error) {
|
|
125
130
|
const fallback = await cache.read();
|
|
126
131
|
if (fallback.length > 0) {
|
|
132
|
+
// SAFETY: everything thrown on this path is an Error — fetch rejects
|
|
133
|
+
// with a TypeError and the source adapters throw Error instances.
|
|
127
134
|
const diagnostic: Diagnostic = {
|
|
128
135
|
code: "BLUME_SOURCE_OFFLINE",
|
|
129
136
|
message: `Source "${name}" could not be fetched (${(error as Error).message}); served ${fallback.length} cached entries.`,
|
|
@@ -131,6 +138,7 @@ export const loadWithCache = async (
|
|
|
131
138
|
};
|
|
132
139
|
return { diagnostics: [diagnostic], entries: fallback };
|
|
133
140
|
}
|
|
141
|
+
// SAFETY: same invariant as above — `fetchEntries` failures are Errors.
|
|
134
142
|
throw new BlumeError({
|
|
135
143
|
code: "BLUME_SOURCE_FETCH_FAILED",
|
|
136
144
|
message: `Source "${name}" failed to load and no cache is available: ${(error as Error).message}`,
|