blume 1.1.0 → 1.1.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 +15 -0
- package/dist/cli/index.js +240 -87
- package/dist/cli/index.js.map +18 -18
- package/dist/types/ai/component-markdown.d.ts +12 -1
- package/dist/types/core/config-input.d.ts +12 -3
- package/dist/types/core/schema.d.ts +19 -6
- package/dist/types/core/types.d.ts +7 -0
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/configuration/ai.mdx +2 -2
- package/docs/configuration/seo.mdx +16 -0
- package/docs/content/navigation.mdx +4 -0
- package/docs/reference/cli.mdx +1 -1
- package/docs/reference/frontmatter.mdx +2 -0
- package/package.json +2 -1
- package/src/ai/component-markdown.ts +39 -11
- package/src/ai/llms.ts +4 -2
- package/src/ai/markdown.ts +5 -1
- package/src/astro/generate.ts +75 -32
- package/src/astro/pages.ts +21 -5
- package/src/astro/templates.ts +28 -6
- package/src/audit/checks/llms.ts +4 -1
- package/src/cli/commands/build.ts +13 -1
- package/src/cli/prepare.ts +10 -2
- package/src/core/config-input.ts +12 -3
- package/src/core/diagnostics.ts +2 -0
- package/src/core/graph.ts +23 -4
- package/src/core/navigation.ts +169 -14
- package/src/core/project-graph.ts +54 -28
- package/src/core/schema.ts +7 -0
- package/src/core/sources/github-releases.ts +65 -2
- package/src/core/types.ts +7 -0
- package/src/markdown/index.ts +1 -0
- package/src/markdown/twoslash.ts +60 -0
- package/src/registry/eject.ts +3 -1
- /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
package/src/astro/templates.ts
CHANGED
|
@@ -410,9 +410,10 @@ export const astroConfigTemplate = (options: {
|
|
|
410
410
|
// Twoslash runs first, before the always-on transformers, but only on fences
|
|
411
411
|
// with the `twoslash` meta (explicitTrigger) — so it's opt-in per block with
|
|
412
412
|
// no config flag; the TypeScript compiler only spins up when a block uses it.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
413
|
+
// Blume's preconfigured transformer compiles with the package's own pinned
|
|
414
|
+
// classic TypeScript, so the user's project can be on any version (see
|
|
415
|
+
// markdown/twoslash.ts).
|
|
416
|
+
const twoslashTransformer = "blumeTwoslashTransformer(), ";
|
|
416
417
|
|
|
417
418
|
// Content links are rewritten to their real served URL: the `deployment.base`
|
|
418
419
|
// subdirectory (Astro doesn't rewrite `<a href>`) layered over the site-wide
|
|
@@ -449,8 +450,8 @@ export const astroConfigTemplate = (options: {
|
|
|
449
450
|
${defineConfigImport}
|
|
450
451
|
import mdx from "@astrojs/mdx";
|
|
451
452
|
import tailwindcss from "@tailwindcss/vite";
|
|
452
|
-
import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers } from "blume/markdown";
|
|
453
|
-
${
|
|
453
|
+
import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers, blumeTwoslashTransformer } from "blume/markdown";
|
|
454
|
+
${reactImport}${vueImport}${svelteImport}${blumeImport}${adapterImport}
|
|
454
455
|
export default defineConfig({
|
|
455
456
|
root: ${JSON.stringify(context.outDir)},
|
|
456
457
|
srcDir: ${JSON.stringify(`${context.outDir}/src`)},
|
|
@@ -1077,7 +1078,9 @@ import data from "blume:data";
|
|
|
1077
1078
|
export const prerender = true;
|
|
1078
1079
|
|
|
1079
1080
|
// Custom (non-content) pages opted into a generated card, baked in at build.
|
|
1080
|
-
|
|
1081
|
+
// The annotation keeps the empty-array case from being an implicit any[]
|
|
1082
|
+
// (ts(7034)) under a strict tsconfig.
|
|
1083
|
+
const customRoutes: { slug: string; title: string }[] = ${JSON.stringify(customRoutes)};
|
|
1081
1084
|
|
|
1082
1085
|
export function getStaticPaths() {
|
|
1083
1086
|
const seen = new Set<string>();
|
|
@@ -1803,6 +1806,23 @@ const islandDirective = (spec: IslandSpec): string =>
|
|
|
1803
1806
|
? `client:only="${spec.framework}"`
|
|
1804
1807
|
: `client:${spec.client}`;
|
|
1805
1808
|
|
|
1809
|
+
/**
|
|
1810
|
+
* Frontmatter `Props` alias mirroring the wrapped component's own props, so
|
|
1811
|
+
* `{...Astro.props}` satisfies required props under `astro check` (the spread
|
|
1812
|
+
* of an untyped `Astro.props` contributes nothing to the JSX props type).
|
|
1813
|
+
* `infer P extends object` rather than `Record<string, unknown>` because
|
|
1814
|
+
* interfaces have no implicit index signature and would miss the narrower
|
|
1815
|
+
* constraint. Non-function component types (Vue/Svelte ambient modules) fall
|
|
1816
|
+
* back to an open record, keeping the untyped permissiveness they had.
|
|
1817
|
+
*/
|
|
1818
|
+
const wrapperPropsType = (name: string): string =>
|
|
1819
|
+
`type Props = typeof ${name} extends (
|
|
1820
|
+
props: infer P extends object,
|
|
1821
|
+
...rest: never[]
|
|
1822
|
+
) => unknown
|
|
1823
|
+
? P
|
|
1824
|
+
: Record<string, unknown>;`;
|
|
1825
|
+
|
|
1806
1826
|
/**
|
|
1807
1827
|
* Generate `.blume/src/generated/islands/<Name>.astro` — a wrapper that renders
|
|
1808
1828
|
* a convention island with its hydration directive applied. Astro client
|
|
@@ -1813,6 +1833,7 @@ export const islandWrapperTemplate = (spec: IslandSpec): string =>
|
|
|
1813
1833
|
`---
|
|
1814
1834
|
// Generated by Blume. Do not edit.
|
|
1815
1835
|
import Island from ${JSON.stringify(spec.file)};
|
|
1836
|
+
${wrapperPropsType("Island")}
|
|
1816
1837
|
---
|
|
1817
1838
|
<Island ${islandDirective(spec)} {...Astro.props}><slot /></Island>
|
|
1818
1839
|
`;
|
|
@@ -1875,6 +1896,7 @@ export const exampleWrapperTemplate = (spec: ExampleSpec): string =>
|
|
|
1875
1896
|
`---
|
|
1876
1897
|
// Generated by Blume. Do not edit.
|
|
1877
1898
|
import Example from ${JSON.stringify(spec.file)};
|
|
1899
|
+
${wrapperPropsType("Example")}
|
|
1878
1900
|
---
|
|
1879
1901
|
<Example ${exampleDirective(spec)}{...Astro.props}><slot /></Example>
|
|
1880
1902
|
`;
|
package/src/audit/checks/llms.ts
CHANGED
|
@@ -91,7 +91,10 @@ export const llmsChecks: CheckModule = {
|
|
|
91
91
|
continue;
|
|
92
92
|
}
|
|
93
93
|
listed.add(path);
|
|
94
|
-
|
|
94
|
+
// A listed target may be a served asset rather than a page — Blume's own
|
|
95
|
+
// llms.txt links the changelog RSS feed — so the file index vouches for
|
|
96
|
+
// it too, the same way redirect targets may land on a served asset.
|
|
97
|
+
if (!context.byUrl.has(path) && !context.files.has(path)) {
|
|
95
98
|
found.push(
|
|
96
99
|
finding(
|
|
97
100
|
"BLUME_AUDIT_LLMS_TXT_STALE_ENTRY",
|
|
@@ -402,6 +402,13 @@ const publishBuildArtifacts = async (
|
|
|
402
402
|
|
|
403
403
|
await runClientAssetChecks(distDir, args);
|
|
404
404
|
|
|
405
|
+
// Only reachable with --no-strict (strict aborts earlier): repeat the missing
|
|
406
|
+
// count next to the success banner so it can't scroll away unseen.
|
|
407
|
+
if (project.droppedPages > 0) {
|
|
408
|
+
logger.warn(
|
|
409
|
+
`${project.droppedPages} page(s) failed frontmatter validation and are missing from this build.`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
405
412
|
logger.success(`Built to ${distDir}`);
|
|
406
413
|
};
|
|
407
414
|
|
|
@@ -440,7 +447,12 @@ export const buildCommand = defineCommand({
|
|
|
440
447
|
description: "Include drafts and unpublished CMS content.",
|
|
441
448
|
type: "boolean",
|
|
442
449
|
},
|
|
443
|
-
strict: {
|
|
450
|
+
strict: {
|
|
451
|
+
default: true,
|
|
452
|
+
description:
|
|
453
|
+
"Fail on error diagnostics (default; pass --no-strict to build anyway, dropping pages that fail validation).",
|
|
454
|
+
type: "boolean",
|
|
455
|
+
},
|
|
444
456
|
},
|
|
445
457
|
meta: {
|
|
446
458
|
description: "Build the docs site for production.",
|
package/src/cli/prepare.ts
CHANGED
|
@@ -83,12 +83,20 @@ export const prepareProject = async (
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const hadErrors = reportDiagnostics(project.diagnostics, options.root);
|
|
86
|
+
const dropped =
|
|
87
|
+
project.droppedPages > 0
|
|
88
|
+
? `${project.droppedPages} page(s) failed frontmatter validation and were dropped from the site. `
|
|
89
|
+
: "";
|
|
86
90
|
if (hadErrors && options.strict) {
|
|
87
|
-
logger.error(
|
|
91
|
+
logger.error(
|
|
92
|
+
`Aborting due to errors. ${dropped}Fix the diagnostics above, or pass --no-strict to continue despite them.`
|
|
93
|
+
);
|
|
88
94
|
process.exit(1);
|
|
89
95
|
}
|
|
90
96
|
if (hasErrors(project.diagnostics) && !options.strict) {
|
|
91
|
-
logger.warn(
|
|
97
|
+
logger.warn(
|
|
98
|
+
`Continuing despite errors. ${dropped}Use --strict to fail instead.`
|
|
99
|
+
);
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
const { warnings } = await generateRuntime(project);
|
package/src/core/config-input.ts
CHANGED
|
@@ -568,9 +568,11 @@ export interface AiConfig {
|
|
|
568
568
|
/**
|
|
569
569
|
* Markdown serializers for custom components in agent-facing output (the
|
|
570
570
|
* `.md` mirror, `llms-full.txt`, MCP `get_page`), keyed by JSX name. Each
|
|
571
|
-
* receives the component's statically-evaluated `props`
|
|
572
|
-
* `
|
|
573
|
-
*
|
|
571
|
+
* receives the component's statically-evaluated `props` (with the page's
|
|
572
|
+
* `frontmatter` in scope, so `prop={frontmatter.status}` resolves), its
|
|
573
|
+
* downleveled `children`, and the page's `frontmatter` data, and returns
|
|
574
|
+
* replacement Markdown — or `null` to leave the JSX verbatim. A same-name
|
|
575
|
+
* entry replaces a built-in serializer.
|
|
574
576
|
*
|
|
575
577
|
* These live in `blume.config.ts` (which is executed at build time), not in
|
|
576
578
|
* `components.tsx` (which is only statically analyzed, never run).
|
|
@@ -768,6 +770,13 @@ export interface OgConfig {
|
|
|
768
770
|
logo?: string;
|
|
769
771
|
/** Optional generated-card colors. */
|
|
770
772
|
palette?: OgPaletteConfig;
|
|
773
|
+
/**
|
|
774
|
+
* Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
|
|
775
|
+
* A custom page has no frontmatter to read, so its card is otherwise titled
|
|
776
|
+
* by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
|
|
777
|
+
* Content pages always take their card headline from the page title.
|
|
778
|
+
*/
|
|
779
|
+
titles?: Record<string, string>;
|
|
771
780
|
}
|
|
772
781
|
|
|
773
782
|
/** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
|
package/src/core/diagnostics.ts
CHANGED
|
@@ -38,12 +38,14 @@ const DOCS_PATHS: Record<string, string> = {
|
|
|
38
38
|
BLUME_CONTENT_ROOT_MISSING: DOCS_CONTENT_SOURCES,
|
|
39
39
|
BLUME_DEAD_LINK: DOCS_REFERENCE_CLI,
|
|
40
40
|
BLUME_DUPLICATE_ROUTE: DOCS_CONTENT_NAVIGATION,
|
|
41
|
+
BLUME_DUPLICATE_SIDEBAR_ORDER: DOCS_CONTENT_NAVIGATION,
|
|
41
42
|
BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
|
|
42
43
|
BLUME_META_INVALID: "/docs/content/meta",
|
|
43
44
|
BLUME_META_LOAD_FAILED: "/docs/content/meta",
|
|
44
45
|
BLUME_MISSING_SECRET: DOCS_DEPLOYMENT,
|
|
45
46
|
BLUME_NAV_DUPLICATE_LABEL: DOCS_CONTENT_NAVIGATION,
|
|
46
47
|
BLUME_NAV_HIDDEN_IN_SIDEBAR: DOCS_CONTENT_NAVIGATION,
|
|
48
|
+
BLUME_NAV_INDEX_TITLE_MISMATCH: DOCS_CONTENT_NAVIGATION,
|
|
47
49
|
BLUME_NAV_MISSING_PAGE: DOCS_CONTENT_NAVIGATION,
|
|
48
50
|
BLUME_NODE_VERSION: "/docs/quickstart",
|
|
49
51
|
BLUME_SERVER_FEATURE_REQUIRED: DOCS_DEPLOYMENT,
|
package/src/core/graph.ts
CHANGED
|
@@ -70,6 +70,7 @@ const localePagesFor = (
|
|
|
70
70
|
if (!present.has(key)) {
|
|
71
71
|
filled.push({
|
|
72
72
|
...source,
|
|
73
|
+
fallback: true,
|
|
73
74
|
locale: code,
|
|
74
75
|
route: withBasePath(basePath, localizeRoute(key, code, i18n)),
|
|
75
76
|
});
|
|
@@ -85,7 +86,8 @@ const buildLocaleNavigation = (
|
|
|
85
86
|
fallback: FallbackLocale,
|
|
86
87
|
fallbackByKey: Map<string, PageRecord>,
|
|
87
88
|
options: BuildContentGraphOptions,
|
|
88
|
-
i18n: ResolvedI18nConfig
|
|
89
|
+
i18n: ResolvedI18nConfig,
|
|
90
|
+
diagnostics: Diagnostic[]
|
|
89
91
|
): Navigation => {
|
|
90
92
|
// Localize internal tab paths — the tab's own and its dropdown items' — so a
|
|
91
93
|
// header tab points to its in-locale route (e.g. `/docs` -> `/fr/docs`);
|
|
@@ -112,6 +114,7 @@ const buildLocaleNavigation = (
|
|
|
112
114
|
);
|
|
113
115
|
return buildNavigation(localePages, {
|
|
114
116
|
basePath: options.basePath ?? "",
|
|
117
|
+
diagnostics,
|
|
115
118
|
display: options.navigation.sidebar.display,
|
|
116
119
|
featured: options.navigation.featured,
|
|
117
120
|
folderMeta: options.folderMeta,
|
|
@@ -137,7 +140,8 @@ const buildLocaleNavigation = (
|
|
|
137
140
|
const buildI18nNavigation = (
|
|
138
141
|
pages: PageRecord[],
|
|
139
142
|
options: BuildContentGraphOptions,
|
|
140
|
-
i18n: ResolvedI18nConfig
|
|
143
|
+
i18n: ResolvedI18nConfig,
|
|
144
|
+
diagnostics: Diagnostic[]
|
|
141
145
|
): {
|
|
142
146
|
navigation: Navigation;
|
|
143
147
|
navigationByLocale: Record<string, Navigation>;
|
|
@@ -155,16 +159,30 @@ const buildI18nNavigation = (
|
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
// Each locale gets an independent tree, so navigation may diverge per language.
|
|
162
|
+
// Untranslated pages are padded into every locale from the fallback, so a tie
|
|
163
|
+
// in shared content would otherwise be re-reported once per locale — dedupe on
|
|
164
|
+
// code + file + message, which are all locale-stable for padded pages. A
|
|
165
|
+
// locale-specific tie names its own translated files/labels and survives.
|
|
158
166
|
const navigationByLocale: Record<string, Navigation> = {};
|
|
167
|
+
const seen = new Set<string>();
|
|
159
168
|
for (const { code } of i18n.locales) {
|
|
169
|
+
const localeDiagnostics: Diagnostic[] = [];
|
|
160
170
|
navigationByLocale[code] = buildLocaleNavigation(
|
|
161
171
|
code,
|
|
162
172
|
pages,
|
|
163
173
|
fallback,
|
|
164
174
|
fallbackByKey,
|
|
165
175
|
options,
|
|
166
|
-
i18n
|
|
176
|
+
i18n,
|
|
177
|
+
localeDiagnostics
|
|
167
178
|
);
|
|
179
|
+
for (const diagnostic of localeDiagnostics) {
|
|
180
|
+
const key = `${diagnostic.code}\n${diagnostic.file ?? ""}\n${diagnostic.message}`;
|
|
181
|
+
if (!seen.has(key)) {
|
|
182
|
+
seen.add(key);
|
|
183
|
+
diagnostics.push(diagnostic);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
168
186
|
}
|
|
169
187
|
const navigation = navigationByLocale[i18n.defaultLocale] ?? {
|
|
170
188
|
featured: [],
|
|
@@ -184,10 +202,11 @@ export const buildContentGraph = (
|
|
|
184
202
|
const { i18n } = options;
|
|
185
203
|
|
|
186
204
|
const { navigation, navigationByLocale } = i18n
|
|
187
|
-
? buildI18nNavigation(pages, options, i18n)
|
|
205
|
+
? buildI18nNavigation(pages, options, i18n, diagnostics)
|
|
188
206
|
: {
|
|
189
207
|
navigation: buildNavigation(pages, {
|
|
190
208
|
basePath: options.basePath ?? "",
|
|
209
|
+
diagnostics,
|
|
191
210
|
display: options.navigation.sidebar.display,
|
|
192
211
|
featured: options.navigation.featured,
|
|
193
212
|
folderMeta: options.folderMeta,
|
package/src/core/navigation.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
SidebarItemConfig,
|
|
8
8
|
} from "./schema.ts";
|
|
9
9
|
import type {
|
|
10
|
+
Diagnostic,
|
|
10
11
|
FeaturedLink,
|
|
11
12
|
NavNode,
|
|
12
13
|
Navigation,
|
|
@@ -56,7 +57,17 @@ interface MutablePage {
|
|
|
56
57
|
badge?: string;
|
|
57
58
|
deprecated?: boolean;
|
|
58
59
|
pageId: string;
|
|
60
|
+
/** Absolute source path (filesystem adapter only), to anchor diagnostics. */
|
|
61
|
+
file?: string;
|
|
59
62
|
order: number;
|
|
63
|
+
/**
|
|
64
|
+
* Whether `order` reflects a deliberate authoring choice (explicit
|
|
65
|
+
* `sidebar.order`, a numeric filename prefix, or a folder-meta `pages` rank)
|
|
66
|
+
* rather than a derived value like a changelog entry's publish date — two
|
|
67
|
+
* changelog entries published on the same day aren't an authoring mistake,
|
|
68
|
+
* so they're excluded from the duplicate-order diagnostic.
|
|
69
|
+
*/
|
|
70
|
+
orderIsAuthored: boolean;
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
interface MutableGroup {
|
|
@@ -110,24 +121,35 @@ const ensureGroup = (
|
|
|
110
121
|
return group;
|
|
111
122
|
};
|
|
112
123
|
|
|
113
|
-
const pageOrder = (
|
|
124
|
+
const pageOrder = (
|
|
125
|
+
page: PageRecord,
|
|
126
|
+
filename: string
|
|
127
|
+
): { order: number; orderIsAuthored: boolean } => {
|
|
114
128
|
if (page.meta.sidebar.order !== undefined) {
|
|
115
|
-
return page.meta.sidebar.order;
|
|
129
|
+
return { order: page.meta.sidebar.order, orderIsAuthored: true };
|
|
116
130
|
}
|
|
117
131
|
if (isIndexStem(filename.replace(extname(filename), ""))) {
|
|
118
|
-
return Number.NEGATIVE_INFINITY;
|
|
132
|
+
return { order: Number.NEGATIVE_INFINITY, orderIsAuthored: false };
|
|
119
133
|
}
|
|
120
134
|
// Changelog entries read newest-first, matching the generated timeline. Sort
|
|
121
135
|
// on the negated publish timestamp so a later date yields a smaller order
|
|
122
136
|
// under the ascending comparator; undated entries fall back to filename order.
|
|
137
|
+
// The date is derived, not an authoring choice, so same-day entries aren't a
|
|
138
|
+
// duplicate-order mistake.
|
|
123
139
|
if (page.contentType === "changelog") {
|
|
124
140
|
const iso = page.meta.date ?? page.meta.changelog?.date;
|
|
125
141
|
const time = iso ? Date.parse(iso) : Number.NaN;
|
|
126
142
|
if (!Number.isNaN(time)) {
|
|
127
|
-
return -time;
|
|
143
|
+
return { order: -time, orderIsAuthored: false };
|
|
128
144
|
}
|
|
129
145
|
}
|
|
130
|
-
|
|
146
|
+
// An undated changelog entry's numeric filename prefix is usually a date
|
|
147
|
+
// (`2024-01-05-release.md`) rather than a rank, so it is derived too.
|
|
148
|
+
const order = numericOrder(filename);
|
|
149
|
+
return {
|
|
150
|
+
order,
|
|
151
|
+
orderIsAuthored: page.contentType !== "changelog" && Number.isFinite(order),
|
|
152
|
+
};
|
|
131
153
|
};
|
|
132
154
|
|
|
133
155
|
/**
|
|
@@ -166,6 +188,9 @@ const applyFolderMeta = (
|
|
|
166
188
|
const position = rank.get(child.key);
|
|
167
189
|
if (position !== undefined) {
|
|
168
190
|
child.order = position;
|
|
191
|
+
if (child.kind === "page") {
|
|
192
|
+
child.orderIsAuthored = true;
|
|
193
|
+
}
|
|
169
194
|
}
|
|
170
195
|
}
|
|
171
196
|
}
|
|
@@ -178,7 +203,108 @@ const applyFolderMeta = (
|
|
|
178
203
|
}
|
|
179
204
|
};
|
|
180
205
|
|
|
181
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Warn when an index page's own frontmatter title diverges from its folder's
|
|
208
|
+
* explicit `meta.title`. The sidebar label and the page's own `<title>`/heading
|
|
209
|
+
* are resolved from two independent sources — under i18n, a translator can
|
|
210
|
+
* update the folder's `meta.ts` and forget the index page's own frontmatter
|
|
211
|
+
* (or vice versa), and a correct-looking sidebar hides the mismatch.
|
|
212
|
+
*
|
|
213
|
+
* Only fires when the page has an explicit frontmatter `title` of its own:
|
|
214
|
+
* when it's absent, `page.title` is derived from the first heading or the
|
|
215
|
+
* filename, so it almost never coincidentally matches a custom folder title —
|
|
216
|
+
* flagging that would be noise on exactly the plain-landing-page case this is
|
|
217
|
+
* least worth warning about. The root group's `meta.title` (an empty
|
|
218
|
+
* `folderPath`) is also skipped: nothing ever renders it as a sidebar label,
|
|
219
|
+
* so a mismatch there wouldn't correspond to anything visible.
|
|
220
|
+
*
|
|
221
|
+
* Fallback-filled pages are exempt: their title belongs to the fallback
|
|
222
|
+
* locale, so comparing it against this locale's `meta.title` would flag every
|
|
223
|
+
* not-yet-translated index page (once per locale) and point the suggestion at
|
|
224
|
+
* the fallback locale's source file, where "fixing" it would break that
|
|
225
|
+
* locale. The default locale's own build still checks the real page.
|
|
226
|
+
*/
|
|
227
|
+
const indexTitleMismatchDiagnostic = (
|
|
228
|
+
page: PageRecord,
|
|
229
|
+
folderPath: string,
|
|
230
|
+
folderMeta: Map<string, FolderMeta>,
|
|
231
|
+
sharedMeta: Map<string, FolderMeta>,
|
|
232
|
+
metaPrefix: string
|
|
233
|
+
): Diagnostic | undefined => {
|
|
234
|
+
if (!page.meta.title || page.fallback || folderPath === "") {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
const meta =
|
|
238
|
+
folderMeta.get(metaKey(folderPath, metaPrefix)) ??
|
|
239
|
+
sharedMeta.get(folderPath);
|
|
240
|
+
if (!meta?.title || meta.title === page.title) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
code: "BLUME_NAV_INDEX_TITLE_MISMATCH",
|
|
245
|
+
file: page.sourcePath ?? page.id,
|
|
246
|
+
message: `Index page "${page.navPath}" has title "${page.title}", but its folder's meta.title is "${meta.title}" — the sidebar shows the folder title while the page's own <title>/heading still say "${page.title}".`,
|
|
247
|
+
severity: "warning",
|
|
248
|
+
suggestion: `Update the page's frontmatter title to match ("${meta.title}"), or leave it if the divergence is intentional.`,
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Whether a node's `order` reflects a deliberate authoring choice. */
|
|
253
|
+
const isAuthoredOrder = (node: MutableNode): boolean =>
|
|
254
|
+
node.kind === "group" || node.orderIsAuthored;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Warn when two sibling nodes share an explicit/numeric order (frontmatter
|
|
258
|
+
* `sidebar.order`, a numeric filename prefix, or folder-meta `order`) — they'd
|
|
259
|
+
* otherwise fall back to a silent, arbitrary alphabetical tiebreak. Nodes at
|
|
260
|
+
* the default fallback order (no numeric prefix, no explicit order) are
|
|
261
|
+
* excluded: that's the common, intentional case of "just sort alphabetically."
|
|
262
|
+
* So is a derived, non-authored order (e.g. two changelog entries published
|
|
263
|
+
* on the same day) — not an authoring mistake.
|
|
264
|
+
*/
|
|
265
|
+
const duplicateOrderDiagnostics = (nodes: MutableNode[]): Diagnostic[] => {
|
|
266
|
+
const byOrder = new Map<number, MutableNode[]>();
|
|
267
|
+
for (const node of nodes) {
|
|
268
|
+
if (!Number.isFinite(node.order) || !isAuthoredOrder(node)) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const tied = byOrder.get(node.order);
|
|
272
|
+
if (tied) {
|
|
273
|
+
tied.push(node);
|
|
274
|
+
} else {
|
|
275
|
+
byOrder.set(node.order, [node]);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const diagnostics: Diagnostic[] = [];
|
|
279
|
+
for (const [order, tied] of byOrder) {
|
|
280
|
+
if (tied.length > 1) {
|
|
281
|
+
const names = tied.map((node) => `"${node.label}"`);
|
|
282
|
+
const list =
|
|
283
|
+
names.length > 2
|
|
284
|
+
? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`
|
|
285
|
+
: names.join(" and ");
|
|
286
|
+
const verb = tied.length > 2 ? "all have" : "both have";
|
|
287
|
+
// Anchor the diagnostic to one tied source file so tooling can point
|
|
288
|
+
// somewhere concrete; the message names the rest. Folder-only ties
|
|
289
|
+
// (folder-meta `order`) have no single file, so `file` stays unset.
|
|
290
|
+
const file = tied.find(
|
|
291
|
+
(node): node is MutablePage => node.kind === "page"
|
|
292
|
+
)?.file;
|
|
293
|
+
diagnostics.push({
|
|
294
|
+
code: "BLUME_DUPLICATE_SIDEBAR_ORDER",
|
|
295
|
+
file,
|
|
296
|
+
message: `${list} ${verb} sidebar order ${order}; falling back to alphabetical order.`,
|
|
297
|
+
severity: "warning",
|
|
298
|
+
suggestion:
|
|
299
|
+
"Give each item a distinct sidebar.order (or folder meta order).",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return diagnostics;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const sortNodes = (nodes: MutableNode[], diagnostics: Diagnostic[]): void => {
|
|
307
|
+
diagnostics.push(...duplicateOrderDiagnostics(nodes));
|
|
182
308
|
nodes.sort((a, b) => {
|
|
183
309
|
if (a.order !== b.order) {
|
|
184
310
|
return a.order - b.order;
|
|
@@ -187,7 +313,7 @@ const sortNodes = (nodes: MutableNode[]): void => {
|
|
|
187
313
|
});
|
|
188
314
|
for (const node of nodes) {
|
|
189
315
|
if (node.kind === "group") {
|
|
190
|
-
sortNodes(node.children);
|
|
316
|
+
sortNodes(node.children, diagnostics);
|
|
191
317
|
}
|
|
192
318
|
}
|
|
193
319
|
};
|
|
@@ -263,20 +389,37 @@ const buildFileSystemSidebar = (
|
|
|
263
389
|
sharedMeta: Map<string, FolderMeta>,
|
|
264
390
|
metaPrefix: string,
|
|
265
391
|
display: SidebarDisplay,
|
|
266
|
-
tabPaths: Set<string
|
|
392
|
+
tabPaths: Set<string>,
|
|
393
|
+
diagnostics: Diagnostic[] = []
|
|
267
394
|
): NavNode[] => {
|
|
268
395
|
const root = createGroup("", "", "", 0);
|
|
269
396
|
|
|
270
397
|
for (const page of pages) {
|
|
271
|
-
if (page.meta.sidebar.hidden) {
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
398
|
// Group by the locale-stripped path so the locale dir is not a nav group.
|
|
275
399
|
const parts = page.navPath.split("/");
|
|
276
400
|
const filename = parts.at(-1) ?? page.navPath;
|
|
277
401
|
const stem = filename.replace(extname(filename), "");
|
|
278
402
|
const dirs = parts.slice(0, -1);
|
|
279
403
|
|
|
404
|
+
// Checked before the hidden filter: a sidebar-hidden index page still
|
|
405
|
+
// renders with its own <title>, so title drift matters there just the same.
|
|
406
|
+
if (isIndexStem(stem)) {
|
|
407
|
+
const diagnostic = indexTitleMismatchDiagnostic(
|
|
408
|
+
page,
|
|
409
|
+
dirs.join("/"),
|
|
410
|
+
folderMeta,
|
|
411
|
+
sharedMeta,
|
|
412
|
+
metaPrefix
|
|
413
|
+
);
|
|
414
|
+
if (diagnostic) {
|
|
415
|
+
diagnostics.push(diagnostic);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (page.meta.sidebar.hidden) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
280
423
|
// Each group's URL path is the matching prefix of the page's route. navPath
|
|
281
424
|
// is locale-stripped while the route may carry a locale/base prefix, so
|
|
282
425
|
// align the folder segments from the right (the extra leading segments are
|
|
@@ -301,22 +444,25 @@ const buildFileSystemSidebar = (
|
|
|
301
444
|
parent.routePath ??= `/${folderParts.slice(0, consumed).join("/")}`;
|
|
302
445
|
}
|
|
303
446
|
|
|
447
|
+
const { order, orderIsAuthored } = pageOrder(page, filename);
|
|
304
448
|
parent.children.push({
|
|
305
449
|
badge: page.meta.sidebar.badge,
|
|
306
450
|
deprecated: page.meta.deprecated || undefined,
|
|
307
451
|
description: page.description,
|
|
452
|
+
file: page.sourcePath,
|
|
308
453
|
icon: page.meta.sidebar.icon,
|
|
309
454
|
key: segmentKey(stem),
|
|
310
455
|
kind: "page",
|
|
311
456
|
label: page.meta.sidebar.label ?? page.title,
|
|
312
|
-
order
|
|
457
|
+
order,
|
|
458
|
+
orderIsAuthored,
|
|
313
459
|
pageId: page.id,
|
|
314
460
|
route: page.route,
|
|
315
461
|
});
|
|
316
462
|
}
|
|
317
463
|
|
|
318
464
|
applyFolderMeta(root, folderMeta, sharedMeta, metaPrefix);
|
|
319
|
-
sortNodes(root.children);
|
|
465
|
+
sortNodes(root.children, diagnostics);
|
|
320
466
|
hoistPages(root.children, display === "flat");
|
|
321
467
|
hoistTabSections(root.children, tabPaths, display === "flat");
|
|
322
468
|
return root.children.map((child) => toNavNode(child, display));
|
|
@@ -500,6 +646,12 @@ export const buildNavigation = (
|
|
|
500
646
|
* scoping.
|
|
501
647
|
*/
|
|
502
648
|
localizedRoot?: string;
|
|
649
|
+
/**
|
|
650
|
+
* Sink for diagnostics produced while building the tree (duplicate sidebar
|
|
651
|
+
* `order` values, index-page title/folder-meta-title mismatches). Pushed
|
|
652
|
+
* into in place; omit to discard.
|
|
653
|
+
*/
|
|
654
|
+
diagnostics?: Diagnostic[];
|
|
503
655
|
}
|
|
504
656
|
): Navigation => {
|
|
505
657
|
const basePath = options.basePath ?? "";
|
|
@@ -583,7 +735,10 @@ export const buildNavigation = (
|
|
|
583
735
|
sharedFolderMeta,
|
|
584
736
|
metaPrefix,
|
|
585
737
|
display,
|
|
586
|
-
new Set(
|
|
738
|
+
new Set(
|
|
739
|
+
tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
|
|
740
|
+
),
|
|
741
|
+
options.diagnostics
|
|
587
742
|
);
|
|
588
743
|
return {
|
|
589
744
|
featured,
|
|
@@ -15,7 +15,7 @@ import { resolveProjectContext } from "./project.ts";
|
|
|
15
15
|
import type { ResolvedConfig } from "./schema.ts";
|
|
16
16
|
import { normalizeEntry } from "./sources/normalize.ts";
|
|
17
17
|
import { resolveDocsCollection, resolveSources } from "./sources/resolve.ts";
|
|
18
|
-
import type { ContentSource } from "./sources/types.ts";
|
|
18
|
+
import type { ContentSource, SourceLoadResult } from "./sources/types.ts";
|
|
19
19
|
import type {
|
|
20
20
|
BlumeManifest,
|
|
21
21
|
ContentGraph,
|
|
@@ -70,6 +70,8 @@ export interface BlumeProject {
|
|
|
70
70
|
graph: ContentGraph;
|
|
71
71
|
manifest: BlumeManifest;
|
|
72
72
|
diagnostics: Diagnostic[];
|
|
73
|
+
/** Entries excluded from the graph because their frontmatter failed validation. */
|
|
74
|
+
droppedPages: number;
|
|
73
75
|
/** The instantiated content sources, for lazy entry reads (search/AI/raw). */
|
|
74
76
|
sources: ContentSource[];
|
|
75
77
|
}
|
|
@@ -116,6 +118,51 @@ const entryIdDiagnostics = (
|
|
|
116
118
|
return diagnostics;
|
|
117
119
|
};
|
|
118
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Funnel every loaded source's entries through the shared `normalizeEntry`,
|
|
123
|
+
* collecting pages, diagnostics, and the count of entries dropped outright —
|
|
124
|
+
* an entry that yields no pages but did yield diagnostics was rejected for
|
|
125
|
+
* invalid frontmatter, and callers surface that count so a build with missing
|
|
126
|
+
* pages can't read as clean.
|
|
127
|
+
*/
|
|
128
|
+
const normalizeLoadedEntries = (
|
|
129
|
+
loaded: ({ source: ContentSource } & SourceLoadResult)[],
|
|
130
|
+
config: ResolvedConfig
|
|
131
|
+
): { pages: PageRecord[]; diagnostics: Diagnostic[]; droppedPages: number } => {
|
|
132
|
+
// Only thread `frontmatter.extend` through when a project opts in, so the
|
|
133
|
+
// known-key split in `normalizeEntry` stays off the default path.
|
|
134
|
+
const frontmatterExtend =
|
|
135
|
+
Object.keys(config.frontmatter.extend).length > 0
|
|
136
|
+
? config.frontmatter.extend
|
|
137
|
+
: undefined;
|
|
138
|
+
|
|
139
|
+
const pages: PageRecord[] = [];
|
|
140
|
+
const allDiagnostics: Diagnostic[] = [];
|
|
141
|
+
let droppedPages = 0;
|
|
142
|
+
for (const { source, entries, diagnostics } of loaded) {
|
|
143
|
+
allDiagnostics.push(...diagnostics);
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const normalized = normalizeEntry(entry, {
|
|
146
|
+
basePath: config.basePath,
|
|
147
|
+
defaultType: config.content.defaultType,
|
|
148
|
+
frontmatterExtend,
|
|
149
|
+
i18n: config.i18n,
|
|
150
|
+
source: {
|
|
151
|
+
name: source.name,
|
|
152
|
+
prefix: source.prefix,
|
|
153
|
+
staged: source.staged,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
if (normalized.pages.length === 0 && normalized.diagnostics.length > 0) {
|
|
157
|
+
droppedPages += 1;
|
|
158
|
+
}
|
|
159
|
+
pages.push(...normalized.pages);
|
|
160
|
+
allDiagnostics.push(...normalized.diagnostics);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { diagnostics: allDiagnostics, droppedPages, pages };
|
|
164
|
+
};
|
|
165
|
+
|
|
119
166
|
/**
|
|
120
167
|
* Run the full core pipeline for a project root: load config, resolve paths,
|
|
121
168
|
* discover content and folder meta, build the graph, and assemble the manifest.
|
|
@@ -190,33 +237,11 @@ export const scanProject = async (
|
|
|
190
237
|
discoverFolderMeta(metaSources, { localeDirs }),
|
|
191
238
|
]);
|
|
192
239
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
: undefined;
|
|
199
|
-
|
|
200
|
-
const allPages: PageRecord[] = [];
|
|
201
|
-
const contentDiagnostics: Diagnostic[] = [];
|
|
202
|
-
for (const { source, entries, diagnostics } of loaded) {
|
|
203
|
-
contentDiagnostics.push(...diagnostics);
|
|
204
|
-
for (const entry of entries) {
|
|
205
|
-
const normalized = normalizeEntry(entry, {
|
|
206
|
-
basePath: config.basePath,
|
|
207
|
-
defaultType: config.content.defaultType,
|
|
208
|
-
frontmatterExtend,
|
|
209
|
-
i18n: config.i18n,
|
|
210
|
-
source: {
|
|
211
|
-
name: source.name,
|
|
212
|
-
prefix: source.prefix,
|
|
213
|
-
staged: source.staged,
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
allPages.push(...normalized.pages);
|
|
217
|
-
contentDiagnostics.push(...normalized.diagnostics);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
240
|
+
const {
|
|
241
|
+
diagnostics: contentDiagnostics,
|
|
242
|
+
droppedPages,
|
|
243
|
+
pages: allPages,
|
|
244
|
+
} = normalizeLoadedEntries(loaded, config);
|
|
220
245
|
|
|
221
246
|
// Drafts render in dev and in preview, but are excluded from production builds.
|
|
222
247
|
const pages =
|
|
@@ -267,6 +292,7 @@ export const scanProject = async (
|
|
|
267
292
|
...graph.diagnostics,
|
|
268
293
|
...i18nWarnings,
|
|
269
294
|
],
|
|
295
|
+
droppedPages,
|
|
270
296
|
graph,
|
|
271
297
|
manifest,
|
|
272
298
|
mode,
|