blume 1.1.0 → 1.1.2
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 +24 -0
- package/dist/cli/index.js +319 -91
- 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 +18 -8
- package/dist/types/core/schema.d.ts +38 -24
- package/dist/types/core/types.d.ts +14 -0
- package/dist/types/markdown/themes.d.ts +21 -0
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/configuration/ai.mdx +2 -2
- package/docs/configuration/index.mdx +1 -1
- package/docs/configuration/seo.mdx +16 -0
- package/docs/content/components.mdx +1 -1
- package/docs/content/navigation.mdx +4 -0
- package/docs/content/syntax.mdx +14 -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 +121 -32
- package/src/astro/pages.ts +21 -5
- package/src/astro/templates.ts +70 -7
- 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/components/content/Component.astro +99 -6
- package/src/components/content/diff.ts +53 -4
- package/src/components/layout/RootLayout.astro +9 -1
- package/src/components/layout/nav-utils.ts +18 -7
- package/src/core/config-input.ts +18 -8
- package/src/core/diagnostics.ts +2 -0
- package/src/core/graph.ts +23 -4
- package/src/core/navigation.ts +180 -21
- package/src/core/project-graph.ts +54 -28
- package/src/core/schema.ts +39 -2
- package/src/core/sources/github-releases.ts +65 -2
- package/src/core/types.ts +14 -0
- package/src/markdown/index.ts +3 -0
- package/src/markdown/inline-code.ts +1 -1
- package/src/markdown/themes.ts +7 -2
- package/src/markdown/twoslash.ts +60 -0
- package/src/registry/eject.ts +3 -1
- /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* a unified patch (string or `.patch`/`.diff` file), a pair of file paths, or a
|
|
9
9
|
* pair of inline strings.
|
|
10
10
|
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
11
12
|
import { readFile } from "node:fs/promises";
|
|
12
13
|
|
|
14
|
+
import { registerCustomTheme } from "@pierre/diffs";
|
|
13
15
|
import { preloadDiffHTML, preloadPatchDiff } from "@pierre/diffs/ssr";
|
|
14
16
|
import { isAbsolute, join } from "pathe";
|
|
15
17
|
|
|
16
18
|
import { DEFAULT_CODE_THEMES } from "../../markdown/themes.ts";
|
|
17
|
-
import type { CodeThemes } from "../../markdown/themes.ts";
|
|
19
|
+
import type { CodeTheme, CodeThemes } from "../../markdown/themes.ts";
|
|
18
20
|
|
|
19
21
|
export interface DiffOptions {
|
|
20
22
|
/** Path to the "after" file, resolved relative to {@link DiffOptions.root}. */
|
|
@@ -46,6 +48,53 @@ const resolvePath = (path: string, root: string): string =>
|
|
|
46
48
|
const readText = (path: string, root: string): Promise<string> =>
|
|
47
49
|
readFile(resolvePath(path, root), "utf-8");
|
|
48
50
|
|
|
51
|
+
const registeredDiffThemes = new WeakMap<object, Map<string, string>>();
|
|
52
|
+
const registeredDiffThemeNames = new Set<string>();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pierre accepts custom Shiki themes through its registry, while Shiki itself
|
|
56
|
+
* accepts the object directly. Register each configured object under a name
|
|
57
|
+
* derived from its content and resolved type, so registration survives
|
|
58
|
+
* dev-server reloads: the same theme resolves to the same name (already
|
|
59
|
+
* registered, so it's skipped — Pierre logs a console error on a same-name
|
|
60
|
+
* re-register), while an edited theme gets a fresh name instead of a stale
|
|
61
|
+
* entry. The memo is keyed per resolved type as well — a typeless object
|
|
62
|
+
* shared between both modes must not hand light mode the dark-typed
|
|
63
|
+
* registration.
|
|
64
|
+
*/
|
|
65
|
+
const diffThemeName = (theme: CodeTheme, mode: "dark" | "light"): string => {
|
|
66
|
+
if (typeof theme === "string") {
|
|
67
|
+
return theme;
|
|
68
|
+
}
|
|
69
|
+
const type = theme.type ?? mode;
|
|
70
|
+
let byType = registeredDiffThemes.get(theme);
|
|
71
|
+
if (!byType) {
|
|
72
|
+
byType = new Map();
|
|
73
|
+
registeredDiffThemes.set(theme, byType);
|
|
74
|
+
}
|
|
75
|
+
const cached = byType.get(type);
|
|
76
|
+
if (cached) {
|
|
77
|
+
return cached;
|
|
78
|
+
}
|
|
79
|
+
const hash = createHash("sha256")
|
|
80
|
+
.update(JSON.stringify(theme))
|
|
81
|
+
.digest("hex")
|
|
82
|
+
.slice(0, 12);
|
|
83
|
+
const name = `blume-custom-${hash}-${type}`;
|
|
84
|
+
if (!registeredDiffThemeNames.has(name)) {
|
|
85
|
+
const registered = { ...theme, name, type };
|
|
86
|
+
registerCustomTheme(name, () => Promise.resolve(registered));
|
|
87
|
+
registeredDiffThemeNames.add(name);
|
|
88
|
+
}
|
|
89
|
+
byType.set(type, name);
|
|
90
|
+
return name;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const diffThemes = (themes: CodeThemes): { dark: string; light: string } => ({
|
|
94
|
+
dark: diffThemeName(themes.dark, "dark"),
|
|
95
|
+
light: diffThemeName(themes.light, "light"),
|
|
96
|
+
});
|
|
97
|
+
|
|
49
98
|
/**
|
|
50
99
|
* Resolve `<Diff>` inputs to a prerendered HTML string. Throws when no input
|
|
51
100
|
* group is supplied or a pair is half-specified, so the component can degrade
|
|
@@ -67,7 +116,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
|
|
|
67
116
|
if (patch !== undefined || src !== undefined) {
|
|
68
117
|
const text = patch ?? (await readText(src as string, root));
|
|
69
118
|
const result = await preloadPatchDiff({
|
|
70
|
-
options: { theme },
|
|
119
|
+
options: { theme: diffThemes(theme) },
|
|
71
120
|
patch: text,
|
|
72
121
|
});
|
|
73
122
|
return result.prerenderedHTML;
|
|
@@ -80,7 +129,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
|
|
|
80
129
|
return await preloadDiffHTML({
|
|
81
130
|
newFile: { contents: await readText(after, root), name: after },
|
|
82
131
|
oldFile: { contents: await readText(before, root), name: before },
|
|
83
|
-
options: { theme },
|
|
132
|
+
options: { theme: diffThemes(theme) },
|
|
84
133
|
});
|
|
85
134
|
}
|
|
86
135
|
|
|
@@ -91,7 +140,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
|
|
|
91
140
|
return await preloadDiffHTML({
|
|
92
141
|
newFile: { contents: newText, lang, name: "snippet" },
|
|
93
142
|
oldFile: { contents: old, lang, name: "snippet" },
|
|
94
|
-
options: { disableFileHeader: true, theme },
|
|
143
|
+
options: { disableFileHeader: true, theme: diffThemes(theme) },
|
|
95
144
|
});
|
|
96
145
|
}
|
|
97
146
|
|
|
@@ -306,7 +306,15 @@ const mcpUrl =
|
|
|
306
306
|
// Scope the sidebar (and the breadcrumbs/pagination derived from it) to the
|
|
307
307
|
// active tab's section, so a multi-section site drills each tab into its own
|
|
308
308
|
// pages. Without tabs — or on a route under none — this is the full sidebar.
|
|
309
|
-
|
|
309
|
+
// `navigation.root` keeps the root-tab check in the tabs' localized/based
|
|
310
|
+
// path space (`/en`, `/docs`), so a locale or base prefix doesn't misread the
|
|
311
|
+
// root tab as a section tab.
|
|
312
|
+
const sidebar = sidebarForRoute(
|
|
313
|
+
navigation.sidebar,
|
|
314
|
+
navigation.tabs,
|
|
315
|
+
page.route,
|
|
316
|
+
navigation.root
|
|
317
|
+
);
|
|
310
318
|
const activeTab = activeTabForRoute(navigation.tabs, page.route);
|
|
311
319
|
const crumbs = findBreadcrumbs(sidebar, page.route);
|
|
312
320
|
const { prev, next } = getPagination(flattenPages(sidebar), page.route);
|
|
@@ -135,12 +135,16 @@ const isTabSection = (node: NavNode, tabPaths: Set<string>): boolean => {
|
|
|
135
135
|
* so a root/un-tabbed route lists only the pages outside every tab's section
|
|
136
136
|
* instead of duplicating each tab as a sidebar group. A container left empty by
|
|
137
137
|
* this pruning is dropped too, so no bare heading is stranded. The root tab
|
|
138
|
-
*
|
|
138
|
+
* spans everything, so it never removes anything.
|
|
139
139
|
*/
|
|
140
|
-
const withoutTabSections = (
|
|
140
|
+
const withoutTabSections = (
|
|
141
|
+
nodes: NavNode[],
|
|
142
|
+
tabs: NavTab[],
|
|
143
|
+
root: string
|
|
144
|
+
): NavNode[] => {
|
|
141
145
|
const tabPaths = new Set<string>();
|
|
142
146
|
for (const tab of tabs) {
|
|
143
|
-
if (tab.path !==
|
|
147
|
+
if (tab.path !== root) {
|
|
144
148
|
tabPaths.add(tab.path);
|
|
145
149
|
}
|
|
146
150
|
}
|
|
@@ -174,9 +178,15 @@ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
|
|
|
174
178
|
* under one tab shows only that tab's group — so a multi-section site (e.g.
|
|
175
179
|
* Adapters / API / AI tabs) drills each tab into its own pages instead of one
|
|
176
180
|
* global tree, the way Fumadocs' root folders do. On a route under no tab (or
|
|
177
|
-
* the root
|
|
181
|
+
* the root tab), the tab-owned groups are hidden so the root sidebar shows
|
|
178
182
|
* only pages that don't belong to a tab.
|
|
179
183
|
*
|
|
184
|
+
* `root` is the tree root in the tabs' own path space (`Navigation.root`) —
|
|
185
|
+
* tab paths arrive localized and based, so under i18n or a `basePath` the root
|
|
186
|
+
* tab is `/en` or `/docs`, not `/`. Comparing against `/` would misread it as
|
|
187
|
+
* a section tab: a root-level `(group)` folder's path is exactly that prefix,
|
|
188
|
+
* so the sidebar collapsed to that one group (or blanked entirely).
|
|
189
|
+
*
|
|
180
190
|
* When a matched tab owns no sidebar group — a standalone page like the
|
|
181
191
|
* generated changelog timeline (`/changelog`), or a tab whose source produced
|
|
182
192
|
* no pages — the sidebar is empty. It must not fall back to the full tree: that
|
|
@@ -187,13 +197,14 @@ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
|
|
|
187
197
|
export const sidebarForRoute = (
|
|
188
198
|
sidebar: NavNode[],
|
|
189
199
|
tabs: NavTab[],
|
|
190
|
-
route: string
|
|
200
|
+
route: string,
|
|
201
|
+
root = "/"
|
|
191
202
|
): NavNode[] => {
|
|
192
203
|
const tab = activeTabForRoute(tabs, route);
|
|
193
|
-
if (tab && tab.path !==
|
|
204
|
+
if (tab && tab.path !== root) {
|
|
194
205
|
return sectionChildren(sidebar, tab.path) ?? [];
|
|
195
206
|
}
|
|
196
|
-
const scoped = withoutTabSections(sidebar, tabs);
|
|
207
|
+
const scoped = withoutTabSections(sidebar, tabs, root);
|
|
197
208
|
return scoped.length > 0 ? scoped : sidebar;
|
|
198
209
|
};
|
|
199
210
|
|
package/src/core/config-input.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
|
|
3
3
|
import type { ComponentMarkdown } from "../ai/component-markdown.ts";
|
|
4
|
+
import type { CodeTheme } from "../markdown/themes.ts";
|
|
4
5
|
import type { FontSlug } from "../theme/fonts.ts";
|
|
5
6
|
import type {
|
|
6
7
|
blumeConfigSchema,
|
|
@@ -568,9 +569,11 @@ export interface AiConfig {
|
|
|
568
569
|
/**
|
|
569
570
|
* Markdown serializers for custom components in agent-facing output (the
|
|
570
571
|
* `.md` mirror, `llms-full.txt`, MCP `get_page`), keyed by JSX name. Each
|
|
571
|
-
* receives the component's statically-evaluated `props`
|
|
572
|
-
* `
|
|
573
|
-
*
|
|
572
|
+
* receives the component's statically-evaluated `props` (with the page's
|
|
573
|
+
* `frontmatter` in scope, so `prop={frontmatter.status}` resolves), its
|
|
574
|
+
* downleveled `children`, and the page's `frontmatter` data, and returns
|
|
575
|
+
* replacement Markdown — or `null` to leave the JSX verbatim. A same-name
|
|
576
|
+
* entry replaces a built-in serializer.
|
|
574
577
|
*
|
|
575
578
|
* These live in `blume.config.ts` (which is executed at build time), not in
|
|
576
579
|
* `components.tsx` (which is only statically analyzed, never run).
|
|
@@ -768,6 +771,13 @@ export interface OgConfig {
|
|
|
768
771
|
logo?: string;
|
|
769
772
|
/** Optional generated-card colors. */
|
|
770
773
|
palette?: OgPaletteConfig;
|
|
774
|
+
/**
|
|
775
|
+
* Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
|
|
776
|
+
* A custom page has no frontmatter to read, so its card is otherwise titled
|
|
777
|
+
* by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
|
|
778
|
+
* Content pages always take their card headline from the page title.
|
|
779
|
+
*/
|
|
780
|
+
titles?: Record<string, string>;
|
|
771
781
|
}
|
|
772
782
|
|
|
773
783
|
/** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
|
|
@@ -839,12 +849,12 @@ export interface MarkdownConfig {
|
|
|
839
849
|
* `` `code`{:lang} ``, `<CodeBlock>`, and `<Diff>`.
|
|
840
850
|
*/
|
|
841
851
|
codeBlocks?: {
|
|
842
|
-
/** Shiki theme names per color mode. */
|
|
852
|
+
/** Bundled Shiki theme names or inline custom Shiki themes per color mode. */
|
|
843
853
|
theme?: {
|
|
844
|
-
/** Dark-mode theme. Defaults to `github-dark`. */
|
|
845
|
-
dark?:
|
|
846
|
-
/** Light-mode theme. Defaults to `github-light`. */
|
|
847
|
-
light?:
|
|
854
|
+
/** Dark-mode theme name or custom theme. Defaults to `github-dark`. */
|
|
855
|
+
dark?: CodeTheme;
|
|
856
|
+
/** Light-mode theme name or custom theme. Defaults to `github-light`. */
|
|
857
|
+
light?: CodeTheme;
|
|
848
858
|
};
|
|
849
859
|
};
|
|
850
860
|
/**
|
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 ?? "";
|
|
@@ -555,6 +707,15 @@ export const buildNavigation = (
|
|
|
555
707
|
}
|
|
556
708
|
}
|
|
557
709
|
|
|
710
|
+
// A tab pointing at the tree root spans the whole sidebar rather than one
|
|
711
|
+
// section, so it must not feed tab-section hoisting. `tabs` carries final
|
|
712
|
+
// paths (localized, then based), so the root is compared in the same space —
|
|
713
|
+
// a root-level `(group)` folder's routePath is exactly the based/localized
|
|
714
|
+
// prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
|
|
715
|
+
// under a base, falsely scope a group named like the prefix). Carried on the
|
|
716
|
+
// returned navigation so render-time scoping compares in the same space too.
|
|
717
|
+
const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
|
|
718
|
+
|
|
558
719
|
if (options.sidebar) {
|
|
559
720
|
const sidebar = buildConfigSidebar(
|
|
560
721
|
options.sidebar,
|
|
@@ -564,29 +725,27 @@ export const buildNavigation = (
|
|
|
564
725
|
);
|
|
565
726
|
return {
|
|
566
727
|
featured,
|
|
728
|
+
root: rootTabPath,
|
|
567
729
|
selectors,
|
|
568
730
|
sidebar,
|
|
569
731
|
tabs: withTabHrefs(tabs, sidebar),
|
|
570
732
|
};
|
|
571
733
|
}
|
|
572
734
|
|
|
573
|
-
// A tab pointing at the tree root spans the whole sidebar rather than one
|
|
574
|
-
// section, so it must not feed tab-section hoisting. `tabs` carries final
|
|
575
|
-
// paths (localized, then based), so the root is compared in the same space —
|
|
576
|
-
// a root-level `(group)` folder's routePath is exactly the based/localized
|
|
577
|
-
// prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
|
|
578
|
-
// under a base, falsely scope a group named like the prefix).
|
|
579
|
-
const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
|
|
580
735
|
const sidebar = buildFileSystemSidebar(
|
|
581
736
|
pages,
|
|
582
737
|
options.folderMeta,
|
|
583
738
|
sharedFolderMeta,
|
|
584
739
|
metaPrefix,
|
|
585
740
|
display,
|
|
586
|
-
new Set(
|
|
741
|
+
new Set(
|
|
742
|
+
tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
|
|
743
|
+
),
|
|
744
|
+
options.diagnostics
|
|
587
745
|
);
|
|
588
746
|
return {
|
|
589
747
|
featured,
|
|
748
|
+
root: rootTabPath,
|
|
590
749
|
selectors,
|
|
591
750
|
sidebar,
|
|
592
751
|
tabs: withTabHrefs(tabs, sidebar),
|