blume 0.6.7 → 0.7.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/dist/cli/index.js +1179 -738
- package/dist/cli/index.js.map +52 -51
- package/dist/types/core/base-path.d.ts +38 -0
- package/dist/types/core/config-input.d.ts +74 -10
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +1 -3
- package/dist/types/core/schema.d.ts +95 -52
- package/dist/types/core/sources/types.d.ts +2 -0
- package/dist/types/core/types.d.ts +6 -1
- package/docs/02-deployment.mdx +16 -1
- package/docs/03-faq.mdx +8 -8
- package/docs/configuration/index.mdx +6 -0
- package/docs/content/components.mdx +29 -2
- package/docs/content/islands.mdx +8 -0
- package/docs/content/syntax.mdx +13 -0
- package/package.json +2 -1
- package/src/ai/agent-readability.ts +7 -2
- package/src/ai/ask.ts +12 -7
- package/src/ai/llms.ts +15 -4
- package/src/ai/mcp/data.ts +8 -4
- package/src/ai/mcp/server.ts +3 -0
- package/src/astro/component-slots.ts +5 -3
- package/src/astro/examples.ts +12 -7
- package/src/astro/generate.ts +317 -144
- package/src/astro/index.ts +5 -1
- package/src/astro/integration.ts +8 -4
- package/src/astro/islands.ts +11 -5
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +8 -3
- package/src/astro/templates.ts +166 -19
- package/src/cli/commands/build.ts +32 -19
- package/src/cli/commands/dev.ts +48 -15
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/validate.ts +1 -0
- package/src/cli/dev-lock.ts +26 -15
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/CodeBlock.astro +3 -0
- package/src/components/content/Component.astro +30 -16
- package/src/components/content/Diff.astro +3 -1
- package/src/components/content/auto-type-table.ts +18 -8
- package/src/components/content/diff.ts +12 -6
- package/src/components/content/mermaid-element.ts +3 -0
- package/src/components/index.ts +23 -1
- package/src/components/islands/ask-ai.tsx +12 -6
- package/src/components/islands/base-path.ts +28 -0
- package/src/components/islands/hooks.ts +16 -1
- package/src/components/layout/Banner.astro +2 -1
- package/src/components/layout/Breadcrumbs.astro +2 -1
- package/src/components/layout/Favicon.astro +3 -2
- package/src/components/layout/Header.astro +2 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +2 -1
- package/src/components/layout/NavSelector.astro +2 -1
- package/src/components/layout/NavTree.astro +5 -4
- package/src/components/layout/PageFeedback.astro +4 -1
- package/src/components/layout/PageLayout.astro +9 -4
- package/src/components/layout/Pagination.astro +3 -2
- package/src/components/layout/RootLayout.astro +7 -4
- package/src/components/layout/Search.astro +13 -5
- package/src/components/layout/nav-utils.ts +18 -10
- package/src/components/layout/search/pagefind.ts +3 -0
- package/src/components/layout/toc-element.ts +7 -1
- package/src/components/openapi/RequestPanel.astro +7 -1
- package/src/components/openapi/snippets.ts +25 -11
- package/src/core/base-path.ts +70 -0
- package/src/core/component-overrides.ts +103 -74
- package/src/core/config-input.ts +81 -15
- package/src/core/config.ts +5 -3
- package/src/core/content.ts +2 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +54 -34
- package/src/core/gitignore.ts +4 -1
- package/src/core/graph.ts +156 -88
- package/src/core/i18n-ui.ts +18 -3
- package/src/core/last-modified.ts +2 -0
- package/src/core/links.ts +38 -18
- package/src/core/manifest.ts +62 -45
- package/src/core/nav-diagnostics.ts +1 -1
- package/src/core/navigation.ts +116 -55
- package/src/core/project-graph.ts +10 -9
- package/src/core/schema.ts +572 -621
- package/src/core/sources/github-releases.ts +2 -1
- package/src/core/sources/mdx-remote.ts +58 -54
- package/src/core/sources/normalize.ts +116 -73
- package/src/core/sources/notion.ts +19 -10
- package/src/core/sources/types.ts +2 -0
- package/src/core/tsconfig-aliases.ts +59 -30
- package/src/core/types.ts +6 -1
- package/src/deploy/redirects.ts +18 -0
- package/src/deploy/robots.ts +6 -1
- package/src/deploy/rss.ts +10 -3
- package/src/deploy/sitemap.ts +14 -10
- package/src/markdown/base-links.ts +58 -0
- package/src/markdown/code-title.ts +11 -14
- package/src/markdown/index.ts +34 -9
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/themes.ts +24 -0
- package/src/openapi/model.ts +3 -1
- package/src/openapi/references.ts +41 -17
- package/src/openapi/render-mdx.ts +11 -6
- package/src/openapi/scalar.ts +32 -16
- package/src/registry/eject.ts +64 -8
- package/src/search/build.ts +3 -0
- package/src/search/documents.ts +2 -2
- package/src/search/sync/typesense.ts +6 -4
- package/src/seo/jsonld.ts +16 -6
- package/src/theme/entry.ts +85 -20
package/src/core/i18n-ui.ts
CHANGED
|
@@ -96,8 +96,23 @@ export const uiStringsSchema = uiStringsObject.default({});
|
|
|
96
96
|
/** A fully-resolved dictionary; every key present. */
|
|
97
97
|
export type UIStrings = z.infer<typeof uiStringsObject>;
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The English baseline, derived from the schema defaults.
|
|
101
|
+
*
|
|
102
|
+
* Each group is passed explicitly as `{}` rather than parsing a bare `{}`: a
|
|
103
|
+
* group's `.default({})` only re-applies its inner field defaults when the group
|
|
104
|
+
* is *present but empty*. Zod 4 changed `.default()` to return the literal
|
|
105
|
+
* default value without re-parsing it through the inner type, so a bare
|
|
106
|
+
* `parse({})` leaves every group undefined and collapses each to `{}` there.
|
|
107
|
+
* Because these components resolve Zod from the consuming project — which may be
|
|
108
|
+
* on v4 while the CLI bundles v3 — the runtime baseline would silently render
|
|
109
|
+
* blank (empty search labels, aria-labels, skip link). Naming every group keeps
|
|
110
|
+
* the baseline fully populated on both Zod 3 and 4.
|
|
111
|
+
*/
|
|
112
|
+
const EN_UI_INPUT: Record<string, unknown> = Object.fromEntries(
|
|
113
|
+
Object.keys(uiStringsObject.shape).map((group) => [group, {}])
|
|
114
|
+
);
|
|
115
|
+
export const EN_UI: UIStrings = uiStringsObject.parse(EN_UI_INPUT);
|
|
101
116
|
|
|
102
117
|
/**
|
|
103
118
|
* A partial override: `{ group: { key: "translation" } }`. Validated loosely
|
|
@@ -138,7 +153,7 @@ const mergeUI = (base: UIStrings, override?: UIStringsOverride): UIStrings => {
|
|
|
138
153
|
* pack so adopters get translated chrome out of the box. Re-exported here so the
|
|
139
154
|
* resolver and existing imports keep a single entry point.
|
|
140
155
|
*/
|
|
141
|
-
export { UI_PACKS };
|
|
156
|
+
export { UI_PACKS } from "./ui-packs/index.ts";
|
|
142
157
|
|
|
143
158
|
/** Case-insensitive index for region-variant lookup (`pt-br` -> `pt-BR`). */
|
|
144
159
|
const PACKS_BY_LOWER: Record<string, UIStringsOverride> = Object.fromEntries(
|
|
@@ -54,11 +54,13 @@ export const gitLastModifiedTimes = (
|
|
|
54
54
|
): Map<string, string> => {
|
|
55
55
|
try {
|
|
56
56
|
const gitRoot = execFileSync(
|
|
57
|
+
// oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
|
|
57
58
|
"git",
|
|
58
59
|
["-C", root, "rev-parse", "--show-toplevel"],
|
|
59
60
|
{ encoding: "utf-8" }
|
|
60
61
|
).trim();
|
|
61
62
|
const output = execFileSync(
|
|
63
|
+
// oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
|
|
62
64
|
"git",
|
|
63
65
|
[
|
|
64
66
|
"-C",
|
package/src/core/links.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
import { basename, join } from "pathe";
|
|
4
4
|
|
|
5
|
+
import { stripBasePath, withBasePath } from "./base-path.ts";
|
|
5
6
|
import type {
|
|
6
7
|
ContentGraph,
|
|
7
8
|
Diagnostic,
|
|
@@ -46,6 +47,8 @@ interface ExternalRef extends LinkSite {
|
|
|
46
47
|
/** Lookups derived once from the content graph. */
|
|
47
48
|
interface LinkContext {
|
|
48
49
|
anchors: Map<string, Set<string>>;
|
|
50
|
+
/** Site-wide route mount point (`""` or `/seg`); routes carry it, assets don't. */
|
|
51
|
+
basePath: string;
|
|
49
52
|
publicDir: string | null;
|
|
50
53
|
/** Normalized `redirect.from` paths — valid targets that resolve at runtime. */
|
|
51
54
|
redirects: Set<string>;
|
|
@@ -70,6 +73,18 @@ const isIndexPage = (page: PageRecord): boolean => {
|
|
|
70
73
|
return /^index\.(?:md|mdx)$/iu.test(basename(ref));
|
|
71
74
|
};
|
|
72
75
|
|
|
76
|
+
/** Apply one relative-path segment to the accumulated route segments. */
|
|
77
|
+
const applyRelativePart = (segments: string[], part: string): void => {
|
|
78
|
+
if (part === "" || part === ".") {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (part === "..") {
|
|
82
|
+
segments.pop();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
segments.push(part);
|
|
86
|
+
};
|
|
87
|
+
|
|
73
88
|
/** Resolve a relative link target against the directory of a page route. */
|
|
74
89
|
const resolveRelative = (
|
|
75
90
|
pageRoute: string,
|
|
@@ -83,14 +98,7 @@ const resolveRelative = (
|
|
|
83
98
|
segments.pop();
|
|
84
99
|
}
|
|
85
100
|
for (const part of target.split("/")) {
|
|
86
|
-
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (part === "..") {
|
|
90
|
-
segments.pop();
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
segments.push(part);
|
|
101
|
+
applyRelativePart(segments, part);
|
|
94
102
|
}
|
|
95
103
|
return `/${segments.join("/")}`;
|
|
96
104
|
};
|
|
@@ -145,10 +153,12 @@ const checkPathLink = (
|
|
|
145
153
|
site: LinkSite,
|
|
146
154
|
ctx: LinkContext
|
|
147
155
|
): LinkResult => {
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
156
|
+
// Page routes carry the site-wide base; an absolute author path is written
|
|
157
|
+
// as if mounted at root, so base it for the route lookup (idempotent — a
|
|
158
|
+
// relative link already resolved against the based `page.route`). A real
|
|
159
|
+
// route always wins over the asset-extension heuristic, so a dotted route
|
|
160
|
+
// (e.g. `/releases/v1.0`) isn't misread as a missing asset.
|
|
161
|
+
const route = toRoute(withBasePath(ctx.basePath, resolved));
|
|
152
162
|
if (ctx.routes.has(route)) {
|
|
153
163
|
return fragment ? checkAnchor(route, fragment, site, ctx) : null;
|
|
154
164
|
}
|
|
@@ -159,8 +169,11 @@ const checkPathLink = (
|
|
|
159
169
|
return null;
|
|
160
170
|
}
|
|
161
171
|
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
// Assets live in `public/` at the site root, unaffected by the base, so strip
|
|
173
|
+
// it back off before probing the filesystem.
|
|
174
|
+
const assetPath = stripBasePath(ctx.basePath, resolved);
|
|
175
|
+
if (FILE_EXT.test(assetPath) && !DOC_EXT.test(assetPath)) {
|
|
176
|
+
if (assetIsPresent(assetPath, ctx)) {
|
|
164
177
|
return null;
|
|
165
178
|
}
|
|
166
179
|
// Nowhere to look: no `public/` directory.
|
|
@@ -170,9 +183,9 @@ const checkPathLink = (
|
|
|
170
183
|
return {
|
|
171
184
|
...site,
|
|
172
185
|
code: "BLUME_BROKEN_ASSET",
|
|
173
|
-
message: `Asset ${
|
|
186
|
+
message: `Asset ${assetPath} was not found in the public directory.`,
|
|
174
187
|
severity: "warning",
|
|
175
|
-
suggestion: `Add the file at public${
|
|
188
|
+
suggestion: `Add the file at public${assetPath} or fix the link.`,
|
|
176
189
|
};
|
|
177
190
|
}
|
|
178
191
|
|
|
@@ -222,10 +235,11 @@ const probe = async (
|
|
|
222
235
|
url: string
|
|
223
236
|
): Promise<Awaited<ReturnType<typeof request>>> => {
|
|
224
237
|
const head = await request(url, "HEAD");
|
|
238
|
+
const unreachable = !head.ok && head.status === undefined && !head.timedOut;
|
|
225
239
|
const retry =
|
|
226
240
|
head.status === STATUS_METHOD_NOT_ALLOWED ||
|
|
227
241
|
head.status === STATUS_NOT_IMPLEMENTED ||
|
|
228
|
-
|
|
242
|
+
unreachable;
|
|
229
243
|
return retry ? await request(url, "GET") : head;
|
|
230
244
|
};
|
|
231
245
|
|
|
@@ -349,17 +363,23 @@ const classifyLink = (
|
|
|
349
363
|
export const validateLinks = async (
|
|
350
364
|
graph: ContentGraph,
|
|
351
365
|
options: {
|
|
366
|
+
/** Site-wide route mount point (`""` or `/seg`); routes and redirects carry it. */
|
|
367
|
+
basePath?: string;
|
|
352
368
|
publicDir: string | null;
|
|
353
369
|
checkExternal?: boolean;
|
|
354
370
|
/** Configured redirects; their `from` paths count as valid link targets. */
|
|
355
371
|
redirects?: { from: string }[];
|
|
356
372
|
}
|
|
357
373
|
): Promise<Diagnostic[]> => {
|
|
374
|
+
const basePath = options.basePath ?? "";
|
|
358
375
|
const ctx: LinkContext = {
|
|
359
376
|
anchors: buildAnchorIndex(graph.pages),
|
|
377
|
+
basePath,
|
|
360
378
|
publicDir: options.publicDir,
|
|
361
379
|
redirects: new Set(
|
|
362
|
-
(options.redirects ?? []).map((redirect) =>
|
|
380
|
+
(options.redirects ?? []).map((redirect) =>
|
|
381
|
+
toRoute(withBasePath(basePath, redirect.from))
|
|
382
|
+
)
|
|
363
383
|
),
|
|
364
384
|
routes: new Set(graph.routes.keys()),
|
|
365
385
|
};
|
package/src/core/manifest.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withBasePath } from "./base-path.ts";
|
|
1
2
|
import { localizeRoute, resolveFallbackLocale } from "./i18n.ts";
|
|
2
3
|
import type { ResolvedConfig } from "./schema.ts";
|
|
3
4
|
import type {
|
|
@@ -26,6 +27,64 @@ export const contentIndexable = (
|
|
|
26
27
|
!page.meta.search.exclude &&
|
|
27
28
|
(!page.meta.sidebar.hidden || config.search.indexing.includeHiddenPages);
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Fallback materialization: render the fallback locale's content at the
|
|
32
|
+
* localized URL for any translation a non-default locale is missing, so static
|
|
33
|
+
* output is fully prerendered (render-fallback, no client redirect). Fallback
|
|
34
|
+
* routes are not indexed and carry no `hreflang` of their own.
|
|
35
|
+
*/
|
|
36
|
+
const buildFallbackRoutes = (
|
|
37
|
+
graph: ContentGraph,
|
|
38
|
+
i18n: NonNullable<ResolvedConfig["i18n"]>,
|
|
39
|
+
alternatesByKey: Map<string, RouteAlternate[]>,
|
|
40
|
+
basePath: string
|
|
41
|
+
): RouteManifestEntry[] => {
|
|
42
|
+
const fallback = resolveFallbackLocale(i18n);
|
|
43
|
+
if (!fallback) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const fallbackPages = new Map(
|
|
47
|
+
graph.pages.flatMap((page) =>
|
|
48
|
+
page.locale === fallback ? [[page.translationKey, page] as const] : []
|
|
49
|
+
)
|
|
50
|
+
);
|
|
51
|
+
const routes: RouteManifestEntry[] = [];
|
|
52
|
+
for (const { code } of i18n.locales) {
|
|
53
|
+
if (code === fallback) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const present = new Set(
|
|
57
|
+
graph.pages.flatMap((page) =>
|
|
58
|
+
page.locale === code ? [page.translationKey] : []
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
for (const [key, source] of fallbackPages) {
|
|
62
|
+
if (present.has(key)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
routes.push({
|
|
66
|
+
alternates: alternatesByKey.get(key) ?? [],
|
|
67
|
+
collection: source.collection ?? "docs",
|
|
68
|
+
contentType: source.contentType,
|
|
69
|
+
draft: source.meta.draft,
|
|
70
|
+
editUrl: source.editUrl,
|
|
71
|
+
entryId: source.entryId ?? source.source.ref,
|
|
72
|
+
fallback: true,
|
|
73
|
+
hidden: source.meta.sidebar.hidden,
|
|
74
|
+
id: source.id,
|
|
75
|
+
indexable: false,
|
|
76
|
+
lastModified: source.lastModified,
|
|
77
|
+
locale: code,
|
|
78
|
+
path: withBasePath(basePath, localizeRoute(key, code, i18n)),
|
|
79
|
+
source: source.source,
|
|
80
|
+
sourcePath: source.sourcePath,
|
|
81
|
+
title: source.title,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return routes;
|
|
86
|
+
};
|
|
87
|
+
|
|
29
88
|
/** Build the runtime manifest that bridges core and the generated Astro app. */
|
|
30
89
|
export const buildManifest = (options: {
|
|
31
90
|
context: ProjectContext;
|
|
@@ -65,52 +124,10 @@ export const buildManifest = (options: {
|
|
|
65
124
|
title: page.title,
|
|
66
125
|
}));
|
|
67
126
|
|
|
68
|
-
// Fallback materialization: render the fallback locale's content at the
|
|
69
|
-
// localized URL for any translation a non-default locale is missing, so static
|
|
70
|
-
// output is fully prerendered (render-fallback, no client redirect). Fallback
|
|
71
|
-
// routes are not indexed and carry no `hreflang` of their own.
|
|
72
127
|
if (i18n) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
graph.pages
|
|
77
|
-
.filter((page) => page.locale === fallback)
|
|
78
|
-
.map((page) => [page.translationKey, page] as const)
|
|
79
|
-
);
|
|
80
|
-
for (const { code } of i18n.locales) {
|
|
81
|
-
if (code === fallback) {
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
const present = new Set(
|
|
85
|
-
graph.pages
|
|
86
|
-
.filter((page) => page.locale === code)
|
|
87
|
-
.map((page) => page.translationKey)
|
|
88
|
-
);
|
|
89
|
-
for (const [key, source] of fallbackPages) {
|
|
90
|
-
if (present.has(key)) {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
routes.push({
|
|
94
|
-
alternates: alternatesByKey.get(key) ?? [],
|
|
95
|
-
collection: source.collection ?? "docs",
|
|
96
|
-
contentType: source.contentType,
|
|
97
|
-
draft: source.meta.draft,
|
|
98
|
-
editUrl: source.editUrl,
|
|
99
|
-
entryId: source.entryId ?? source.source.ref,
|
|
100
|
-
fallback: true,
|
|
101
|
-
hidden: source.meta.sidebar.hidden,
|
|
102
|
-
id: source.id,
|
|
103
|
-
indexable: false,
|
|
104
|
-
lastModified: source.lastModified,
|
|
105
|
-
locale: code,
|
|
106
|
-
path: localizeRoute(key, code, i18n),
|
|
107
|
-
source: source.source,
|
|
108
|
-
sourcePath: source.sourcePath,
|
|
109
|
-
title: source.title,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
128
|
+
routes.push(
|
|
129
|
+
...buildFallbackRoutes(graph, i18n, alternatesByKey, config.basePath)
|
|
130
|
+
);
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
routes.sort((a, b) => a.path.localeCompare(b.path));
|
|
@@ -157,7 +157,7 @@ const hiddenInSidebarDiagnostics = (
|
|
|
157
157
|
pages: PageRecord[]
|
|
158
158
|
): Diagnostic[] => {
|
|
159
159
|
const hidden = new Set(
|
|
160
|
-
pages.
|
|
160
|
+
pages.flatMap((page) => (page.meta.sidebar.hidden ? [page.id] : []))
|
|
161
161
|
);
|
|
162
162
|
if (hidden.size === 0) {
|
|
163
163
|
return [];
|
package/src/core/navigation.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { extname } from "pathe";
|
|
2
2
|
|
|
3
|
+
import { stripBasePath, withBasePath } from "./base-path.ts";
|
|
3
4
|
import type {
|
|
4
5
|
FolderMeta,
|
|
5
6
|
SidebarDisplay,
|
|
@@ -28,7 +29,7 @@ const humanize = (segment: string): string =>
|
|
|
28
29
|
|
|
29
30
|
const numericOrder = (segment: string): number => {
|
|
30
31
|
const value = segment.match(NUMERIC_PREFIX)?.groups?.order;
|
|
31
|
-
return value ?
|
|
32
|
+
return value ? Math.trunc(Number(value)) : Number.POSITIVE_INFINITY;
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
/** The nav key of a raw path segment: group label or numeric-stripped name. */
|
|
@@ -326,82 +327,100 @@ const normalizeRef = (ref: string): string => {
|
|
|
326
327
|
|
|
327
328
|
const routeForRef = (
|
|
328
329
|
ref: string | undefined,
|
|
329
|
-
byRoute: Map<string, PageRecord
|
|
330
|
+
byRoute: Map<string, PageRecord>,
|
|
331
|
+
basePath: string
|
|
330
332
|
): string | undefined => {
|
|
331
333
|
if (!ref) {
|
|
332
334
|
return undefined;
|
|
333
335
|
}
|
|
334
336
|
const normalized = normalizeRef(ref);
|
|
335
|
-
|
|
337
|
+
// A matched page carries an already-based `route`; an unmatched ref is an
|
|
338
|
+
// author-written root-relative path that still needs the base applied.
|
|
339
|
+
return byRoute.get(normalized)?.route ?? withBasePath(basePath, normalized);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Convert one non-group explicit-config sidebar item (string ref, `root`, or
|
|
344
|
+
* `href`) to a nav node, or null to skip. Group items (`item.items`) are handled
|
|
345
|
+
* by `buildConfigSidebar` itself so it owns the recursion.
|
|
346
|
+
*/
|
|
347
|
+
const configItemToNode = (
|
|
348
|
+
item: SidebarItemConfig,
|
|
349
|
+
byRoute: Map<string, PageRecord>,
|
|
350
|
+
basePath: string
|
|
351
|
+
): NavNode | null => {
|
|
352
|
+
if (typeof item === "string") {
|
|
353
|
+
const page = byRoute.get(normalizeRef(item));
|
|
354
|
+
if (!page) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
badge: page.meta.sidebar.badge,
|
|
359
|
+
deprecated: page.meta.deprecated || undefined,
|
|
360
|
+
description: page.description,
|
|
361
|
+
icon: page.meta.sidebar.icon,
|
|
362
|
+
kind: "page",
|
|
363
|
+
label: page.meta.sidebar.label ?? page.title,
|
|
364
|
+
pageId: page.id,
|
|
365
|
+
route: page.route,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (item.root) {
|
|
370
|
+
const page = byRoute.get(normalizeRef(item.root));
|
|
371
|
+
return {
|
|
372
|
+
badge: item.badge,
|
|
373
|
+
deprecated: page?.meta.deprecated || undefined,
|
|
374
|
+
icon: item.icon,
|
|
375
|
+
kind: "page",
|
|
376
|
+
label: item.label,
|
|
377
|
+
pageId: page?.id ?? "",
|
|
378
|
+
route: page?.route ?? withBasePath(basePath, normalizeRef(item.root)),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (item.href) {
|
|
383
|
+
return {
|
|
384
|
+
badge: item.badge,
|
|
385
|
+
icon: item.icon,
|
|
386
|
+
kind: "page",
|
|
387
|
+
label: item.label,
|
|
388
|
+
pageId: "",
|
|
389
|
+
route: withBasePath(basePath, item.href),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return null;
|
|
336
394
|
};
|
|
337
395
|
|
|
338
396
|
/** Build the sidebar tree from an explicit config spec. */
|
|
339
397
|
const buildConfigSidebar = (
|
|
340
398
|
items: SidebarItemConfig[],
|
|
341
399
|
byRoute: Map<string, PageRecord>,
|
|
342
|
-
display: SidebarDisplay
|
|
400
|
+
display: SidebarDisplay,
|
|
401
|
+
basePath: string
|
|
343
402
|
): NavNode[] => {
|
|
344
403
|
const nodes: NavNode[] = [];
|
|
345
|
-
|
|
346
404
|
for (const item of items) {
|
|
347
|
-
if (typeof item
|
|
348
|
-
const page = byRoute.get(normalizeRef(item));
|
|
349
|
-
if (page) {
|
|
350
|
-
nodes.push({
|
|
351
|
-
badge: page.meta.sidebar.badge,
|
|
352
|
-
deprecated: page.meta.deprecated || undefined,
|
|
353
|
-
description: page.description,
|
|
354
|
-
icon: page.meta.sidebar.icon,
|
|
355
|
-
kind: "page",
|
|
356
|
-
label: page.meta.sidebar.label ?? page.title,
|
|
357
|
-
pageId: page.id,
|
|
358
|
-
route: page.route,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (item.items) {
|
|
405
|
+
if (typeof item !== "string" && item.items) {
|
|
365
406
|
nodes.push({
|
|
366
407
|
badge: item.badge,
|
|
367
|
-
children: buildConfigSidebar(item.items, byRoute, display),
|
|
408
|
+
children: buildConfigSidebar(item.items, byRoute, display, basePath),
|
|
368
409
|
collapsed: item.collapsed,
|
|
369
410
|
directory: item.directory,
|
|
370
411
|
display: item.display ?? display,
|
|
371
412
|
icon: item.icon,
|
|
372
413
|
kind: "group",
|
|
373
414
|
label: item.label,
|
|
374
|
-
route: routeForRef(item.root, byRoute),
|
|
415
|
+
route: routeForRef(item.root, byRoute, basePath),
|
|
375
416
|
});
|
|
376
417
|
continue;
|
|
377
418
|
}
|
|
378
|
-
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
nodes.push({
|
|
382
|
-
badge: item.badge,
|
|
383
|
-
deprecated: page?.meta.deprecated || undefined,
|
|
384
|
-
icon: item.icon,
|
|
385
|
-
kind: "page",
|
|
386
|
-
label: item.label,
|
|
387
|
-
pageId: page?.id ?? "",
|
|
388
|
-
route: page?.route ?? normalizeRef(item.root),
|
|
389
|
-
});
|
|
390
|
-
continue;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (item.href) {
|
|
394
|
-
nodes.push({
|
|
395
|
-
badge: item.badge,
|
|
396
|
-
icon: item.icon,
|
|
397
|
-
kind: "page",
|
|
398
|
-
label: item.label,
|
|
399
|
-
pageId: "",
|
|
400
|
-
route: item.href,
|
|
401
|
-
});
|
|
419
|
+
const node = configItemToNode(item, byRoute, basePath);
|
|
420
|
+
if (node) {
|
|
421
|
+
nodes.push(node);
|
|
402
422
|
}
|
|
403
423
|
}
|
|
404
|
-
|
|
405
424
|
return nodes;
|
|
406
425
|
};
|
|
407
426
|
|
|
@@ -409,6 +428,8 @@ const buildConfigSidebar = (
|
|
|
409
428
|
export const buildNavigation = (
|
|
410
429
|
pages: PageRecord[],
|
|
411
430
|
options: {
|
|
431
|
+
/** Site-wide route mount point (`""` or `/seg`); applied to config paths. */
|
|
432
|
+
basePath?: string;
|
|
412
433
|
folderMeta: Map<string, FolderMeta>;
|
|
413
434
|
/** Global display mode for every sidebar group (default `flat`). */
|
|
414
435
|
display?: SidebarDisplay;
|
|
@@ -428,24 +449,64 @@ export const buildNavigation = (
|
|
|
428
449
|
sharedFolderMeta?: Map<string, FolderMeta>;
|
|
429
450
|
}
|
|
430
451
|
): Navigation => {
|
|
431
|
-
const
|
|
432
|
-
const selectors = options.selectors ?? [];
|
|
433
|
-
const tabs = options.tabs ?? [];
|
|
452
|
+
const basePath = options.basePath ?? "";
|
|
434
453
|
const display = options.display ?? "flat";
|
|
435
454
|
const metaPrefix = options.metaPrefix ?? "";
|
|
436
455
|
const sharedFolderMeta = options.sharedFolderMeta ?? new Map();
|
|
456
|
+
|
|
457
|
+
// Config-provided nav paths are authored as if mounted at root, so the base
|
|
458
|
+
// is applied here (idempotently, and only to internal paths — external URLs
|
|
459
|
+
// pass through). Content-derived sidebar routes are already based via
|
|
460
|
+
// `page.route`. The based tab paths also feed tab-scoping below, so they must
|
|
461
|
+
// agree with the based content routes. With no base, this is a pure pass-
|
|
462
|
+
// through — the arrays keep their exact authored shape.
|
|
463
|
+
const rebasePath = <T extends { path: string }>(item: T): T => ({
|
|
464
|
+
...item,
|
|
465
|
+
path: withBasePath(basePath, item.path),
|
|
466
|
+
});
|
|
467
|
+
const featured = basePath
|
|
468
|
+
? (options.featured ?? []).map((link) => ({
|
|
469
|
+
...link,
|
|
470
|
+
href: withBasePath(basePath, link.href),
|
|
471
|
+
}))
|
|
472
|
+
: (options.featured ?? []);
|
|
473
|
+
const selectors = basePath
|
|
474
|
+
? (options.selectors ?? []).map((selector) => ({
|
|
475
|
+
...selector,
|
|
476
|
+
items: selector.items.map(rebasePath),
|
|
477
|
+
}))
|
|
478
|
+
: (options.selectors ?? []);
|
|
479
|
+
const tabs = basePath
|
|
480
|
+
? (options.tabs ?? []).map((tab) => ({
|
|
481
|
+
...tab,
|
|
482
|
+
items: tab.items?.map(rebasePath),
|
|
483
|
+
path: withBasePath(basePath, tab.path),
|
|
484
|
+
}))
|
|
485
|
+
: (options.tabs ?? []);
|
|
437
486
|
const byRoute = new Map(
|
|
438
487
|
pages.map((page) => [
|
|
439
488
|
options.refByLogical ? page.translationKey : page.route,
|
|
440
489
|
page,
|
|
441
490
|
])
|
|
442
491
|
);
|
|
492
|
+
// Explicit-sidebar refs (`"foo/index"`) are authored as if mounted at root,
|
|
493
|
+
// but `page.route` carries the base — alias each page under its base-less
|
|
494
|
+
// route so a bare ref still resolves. (The i18n `refByLogical` map is already
|
|
495
|
+
// keyed by the base-less `translationKey`, so it needs no alias.)
|
|
496
|
+
if (basePath && !options.refByLogical) {
|
|
497
|
+
for (const page of pages) {
|
|
498
|
+
const bare = stripBasePath(basePath, page.route);
|
|
499
|
+
if (!byRoute.has(bare)) {
|
|
500
|
+
byRoute.set(bare, page);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
443
504
|
|
|
444
505
|
if (options.sidebar) {
|
|
445
506
|
return {
|
|
446
507
|
featured,
|
|
447
508
|
selectors,
|
|
448
|
-
sidebar: buildConfigSidebar(options.sidebar, byRoute, display),
|
|
509
|
+
sidebar: buildConfigSidebar(options.sidebar, byRoute, display, basePath),
|
|
449
510
|
tabs,
|
|
450
511
|
};
|
|
451
512
|
}
|
|
@@ -459,7 +520,7 @@ export const buildNavigation = (
|
|
|
459
520
|
sharedFolderMeta,
|
|
460
521
|
metaPrefix,
|
|
461
522
|
display,
|
|
462
|
-
new Set(tabs.
|
|
523
|
+
new Set(tabs.flatMap((tab) => (tab.path === "/" ? [] : [tab.path])))
|
|
463
524
|
),
|
|
464
525
|
tabs,
|
|
465
526
|
};
|
|
@@ -158,12 +158,11 @@ export const scanProject = async (
|
|
|
158
158
|
// Folder meta is discovered per filesystem source, under each source's own
|
|
159
159
|
// root and keyed by its route prefix, so a prefixed/root-differing source's
|
|
160
160
|
// `meta.ts` still lines up with its (prefixed) sidebar group path.
|
|
161
|
-
const metaSources: FolderMetaSource[] = sources
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
prefix: source.prefix,
|
|
165
|
-
|
|
166
|
-
}));
|
|
161
|
+
const metaSources: FolderMetaSource[] = sources.flatMap((source) =>
|
|
162
|
+
source.staged || !source.contentRoot
|
|
163
|
+
? []
|
|
164
|
+
: [{ prefix: source.prefix, root: source.contentRoot }]
|
|
165
|
+
);
|
|
167
166
|
|
|
168
167
|
// Run every source's `load()` in parallel, then funnel each entry through the
|
|
169
168
|
// shared `normalizeEntry` so route mapping is identical regardless of origin.
|
|
@@ -172,9 +171,9 @@ export const scanProject = async (
|
|
|
172
171
|
// `discoverFolderMeta`).
|
|
173
172
|
const localeDirs =
|
|
174
173
|
config.i18n && config.i18n.parser === "dir"
|
|
175
|
-
? config.i18n.locales
|
|
176
|
-
.
|
|
177
|
-
|
|
174
|
+
? config.i18n.locales.flatMap((locale) =>
|
|
175
|
+
locale.code === config.i18n?.defaultLocale ? [] : [locale.code]
|
|
176
|
+
)
|
|
178
177
|
: undefined;
|
|
179
178
|
|
|
180
179
|
const [loaded, folderMeta] = await Promise.all([
|
|
@@ -190,6 +189,7 @@ export const scanProject = async (
|
|
|
190
189
|
contentDiagnostics.push(...diagnostics);
|
|
191
190
|
for (const entry of entries) {
|
|
192
191
|
const normalized = normalizeEntry(entry, {
|
|
192
|
+
basePath: config.basePath,
|
|
193
193
|
defaultType: config.content.defaultType,
|
|
194
194
|
i18n: config.i18n,
|
|
195
195
|
source: {
|
|
@@ -230,6 +230,7 @@ export const scanProject = async (
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
const graph = buildContentGraph(pages, {
|
|
233
|
+
basePath: config.basePath,
|
|
233
234
|
folderMeta: folderMeta.meta,
|
|
234
235
|
i18n: config.i18n,
|
|
235
236
|
navigation: config.navigation,
|