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.
Files changed (108) hide show
  1. package/dist/cli/index.js +1179 -738
  2. package/dist/cli/index.js.map +52 -51
  3. package/dist/types/core/base-path.d.ts +38 -0
  4. package/dist/types/core/config-input.d.ts +74 -10
  5. package/dist/types/core/config.d.ts +3 -2
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +1 -3
  8. package/dist/types/core/schema.d.ts +95 -52
  9. package/dist/types/core/sources/types.d.ts +2 -0
  10. package/dist/types/core/types.d.ts +6 -1
  11. package/docs/02-deployment.mdx +16 -1
  12. package/docs/03-faq.mdx +8 -8
  13. package/docs/configuration/index.mdx +6 -0
  14. package/docs/content/components.mdx +29 -2
  15. package/docs/content/islands.mdx +8 -0
  16. package/docs/content/syntax.mdx +13 -0
  17. package/package.json +2 -1
  18. package/src/ai/agent-readability.ts +7 -2
  19. package/src/ai/ask.ts +12 -7
  20. package/src/ai/llms.ts +15 -4
  21. package/src/ai/mcp/data.ts +8 -4
  22. package/src/ai/mcp/server.ts +3 -0
  23. package/src/astro/component-slots.ts +5 -3
  24. package/src/astro/examples.ts +12 -7
  25. package/src/astro/generate.ts +317 -144
  26. package/src/astro/index.ts +5 -1
  27. package/src/astro/integration.ts +8 -4
  28. package/src/astro/islands.ts +11 -5
  29. package/src/astro/markdown-negotiation.ts +1 -1
  30. package/src/astro/pages.ts +8 -3
  31. package/src/astro/templates.ts +166 -19
  32. package/src/cli/commands/build.ts +32 -19
  33. package/src/cli/commands/dev.ts +48 -15
  34. package/src/cli/commands/doctor.ts +2 -2
  35. package/src/cli/commands/validate.ts +1 -0
  36. package/src/cli/dev-lock.ts +26 -15
  37. package/src/cli/required-secrets.ts +2 -1
  38. package/src/components/content/CodeBlock.astro +3 -0
  39. package/src/components/content/Component.astro +30 -16
  40. package/src/components/content/Diff.astro +3 -1
  41. package/src/components/content/auto-type-table.ts +18 -8
  42. package/src/components/content/diff.ts +12 -6
  43. package/src/components/content/mermaid-element.ts +3 -0
  44. package/src/components/index.ts +23 -1
  45. package/src/components/islands/ask-ai.tsx +12 -6
  46. package/src/components/islands/base-path.ts +28 -0
  47. package/src/components/islands/hooks.ts +16 -1
  48. package/src/components/layout/Banner.astro +2 -1
  49. package/src/components/layout/Breadcrumbs.astro +2 -1
  50. package/src/components/layout/Favicon.astro +3 -2
  51. package/src/components/layout/Header.astro +2 -1
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +2 -1
  54. package/src/components/layout/NavSelector.astro +2 -1
  55. package/src/components/layout/NavTree.astro +5 -4
  56. package/src/components/layout/PageFeedback.astro +4 -1
  57. package/src/components/layout/PageLayout.astro +9 -4
  58. package/src/components/layout/Pagination.astro +3 -2
  59. package/src/components/layout/RootLayout.astro +7 -4
  60. package/src/components/layout/Search.astro +13 -5
  61. package/src/components/layout/nav-utils.ts +18 -10
  62. package/src/components/layout/search/pagefind.ts +3 -0
  63. package/src/components/layout/toc-element.ts +7 -1
  64. package/src/components/openapi/RequestPanel.astro +7 -1
  65. package/src/components/openapi/snippets.ts +25 -11
  66. package/src/core/base-path.ts +70 -0
  67. package/src/core/component-overrides.ts +103 -74
  68. package/src/core/config-input.ts +81 -15
  69. package/src/core/config.ts +5 -3
  70. package/src/core/content.ts +2 -0
  71. package/src/core/data.ts +2 -0
  72. package/src/core/diagnostics.ts +54 -34
  73. package/src/core/gitignore.ts +4 -1
  74. package/src/core/graph.ts +156 -88
  75. package/src/core/i18n-ui.ts +18 -3
  76. package/src/core/last-modified.ts +2 -0
  77. package/src/core/links.ts +38 -18
  78. package/src/core/manifest.ts +62 -45
  79. package/src/core/nav-diagnostics.ts +1 -1
  80. package/src/core/navigation.ts +116 -55
  81. package/src/core/project-graph.ts +10 -9
  82. package/src/core/schema.ts +572 -621
  83. package/src/core/sources/github-releases.ts +2 -1
  84. package/src/core/sources/mdx-remote.ts +58 -54
  85. package/src/core/sources/normalize.ts +116 -73
  86. package/src/core/sources/notion.ts +19 -10
  87. package/src/core/sources/types.ts +2 -0
  88. package/src/core/tsconfig-aliases.ts +59 -30
  89. package/src/core/types.ts +6 -1
  90. package/src/deploy/redirects.ts +18 -0
  91. package/src/deploy/robots.ts +6 -1
  92. package/src/deploy/rss.ts +10 -3
  93. package/src/deploy/sitemap.ts +14 -10
  94. package/src/markdown/base-links.ts +58 -0
  95. package/src/markdown/code-title.ts +11 -14
  96. package/src/markdown/index.ts +34 -9
  97. package/src/markdown/inline-code.ts +7 -2
  98. package/src/markdown/themes.ts +24 -0
  99. package/src/openapi/model.ts +3 -1
  100. package/src/openapi/references.ts +41 -17
  101. package/src/openapi/render-mdx.ts +11 -6
  102. package/src/openapi/scalar.ts +32 -16
  103. package/src/registry/eject.ts +64 -8
  104. package/src/search/build.ts +3 -0
  105. package/src/search/documents.ts +2 -2
  106. package/src/search/sync/typesense.ts +6 -4
  107. package/src/seo/jsonld.ts +16 -6
  108. package/src/theme/entry.ts +85 -20
@@ -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
- /** The English baseline, derived from the schema defaults. */
100
- export const EN_UI: UIStrings = uiStringsObject.parse({});
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
- if (part === "" || part === ".") {
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
- // A real route always wins over the asset-extension heuristic, so a path
149
- // whose last segment merely contains a dot (e.g. a page at `/releases/v1.0`)
150
- // isn't misread as a missing asset.
151
- const route = toRoute(resolved);
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
- if (FILE_EXT.test(resolved) && !DOC_EXT.test(resolved)) {
163
- if (assetIsPresent(resolved, ctx)) {
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 ${resolved} was not found in the public directory.`,
186
+ message: `Asset ${assetPath} was not found in the public directory.`,
174
187
  severity: "warning",
175
- suggestion: `Add the file at public${resolved} or fix the link.`,
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
- (!head.ok && head.status === undefined && !head.timedOut);
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) => toRoute(redirect.from))
380
+ (options.redirects ?? []).map((redirect) =>
381
+ toRoute(withBasePath(basePath, redirect.from))
382
+ )
363
383
  ),
364
384
  routes: new Set(graph.routes.keys()),
365
385
  };
@@ -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
- const fallback = resolveFallbackLocale(i18n);
74
- if (fallback) {
75
- const fallbackPages = new Map(
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.filter((page) => page.meta.sidebar.hidden).map((page) => page.id)
160
+ pages.flatMap((page) => (page.meta.sidebar.hidden ? [page.id] : []))
161
161
  );
162
162
  if (hidden.size === 0) {
163
163
  return [];
@@ -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 ? Number.parseInt(value, 10) : Number.POSITIVE_INFINITY;
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
- return byRoute.get(normalized)?.route ?? normalized;
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 === "string") {
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 (item.root) {
380
- const page = byRoute.get(normalizeRef(item.root));
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 featured = options.featured ?? [];
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.map((tab) => tab.path).filter((path) => path !== "/"))
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
- .filter((source) => !source.staged && source.contentRoot)
163
- .map((source) => ({
164
- prefix: source.prefix,
165
- root: source.contentRoot ?? "",
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
- .map((locale) => locale.code)
177
- .filter((code) => code !== config.i18n?.defaultLocale)
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,