blume 0.5.3 → 0.6.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 (132) hide show
  1. package/dist/cli/index.js +3349 -7024
  2. package/dist/cli/index.js.map +39 -69
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +6 -2
  5. package/dist/types/core/i18n-ui.d.ts +50 -0
  6. package/dist/types/core/schema.d.ts +379 -485
  7. package/dist/types/core/types.d.ts +8 -6
  8. package/docs/advanced/meta.ts +1 -8
  9. package/docs/advanced/skills.mdx +28 -0
  10. package/docs/configuration/ai.mdx +58 -0
  11. package/docs/configuration/index.mdx +13 -17
  12. package/docs/configuration/seo.mdx +59 -1
  13. package/docs/configuration/theming.mdx +15 -18
  14. package/docs/content/components.mdx +2 -53
  15. package/docs/content/i18n.mdx +0 -4
  16. package/docs/content/meta.mdx +3 -17
  17. package/docs/content/navigation.mdx +41 -4
  18. package/docs/content/syntax.mdx +1 -1
  19. package/docs/index.mdx +0 -1
  20. package/docs/reference/cli.mdx +12 -13
  21. package/package.json +6 -6
  22. package/skills/blume/SKILL.md +71 -0
  23. package/skills/blume-update-docs/SKILL.md +52 -0
  24. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  25. package/src/ai/agent-readability.ts +97 -0
  26. package/src/ai/ask-context.ts +131 -8
  27. package/src/ai/ask-data.ts +4 -1
  28. package/src/astro/generate.ts +19 -12
  29. package/src/astro/integration.ts +0 -21
  30. package/src/astro/templates.ts +33 -21
  31. package/src/cli/commands/build.ts +15 -0
  32. package/src/cli/commands/dev.ts +31 -20
  33. package/src/cli/commands/validate.ts +0 -2
  34. package/src/cli/dev-lock.ts +94 -21
  35. package/src/cli/index.ts +0 -2
  36. package/src/components/BlumePage.astro +0 -6
  37. package/src/components/Icon.astro +1 -12
  38. package/src/components/content/AccordionItem.astro +3 -6
  39. package/src/components/content/Badge.astro +1 -3
  40. package/src/components/content/Callout.astro +3 -9
  41. package/src/components/content/Card.astro +2 -3
  42. package/src/components/content/ColorItem.astro +2 -2
  43. package/src/components/content/Column.astro +1 -1
  44. package/src/components/content/GithubInfo.astro +11 -10
  45. package/src/components/content/Prompt.astro +1 -1
  46. package/src/components/content/Step.astro +3 -4
  47. package/src/components/content/Tab.astro +2 -3
  48. package/src/components/content/TypeTable.astro +13 -8
  49. package/src/components/content/Update.astro +1 -1
  50. package/src/components/islands/AskAI.astro +66 -2
  51. package/src/components/islands/ask-ai.tsx +289 -53
  52. package/src/components/layout/Header.astro +27 -4
  53. package/src/components/layout/Logo.astro +5 -1
  54. package/src/components/layout/NavSelector.astro +1 -1
  55. package/src/components/layout/NavTree.astro +15 -15
  56. package/src/components/layout/PageActions.astro +73 -30
  57. package/src/components/layout/PageLayout.astro +42 -0
  58. package/src/components/layout/ReferenceLayout.astro +1 -0
  59. package/src/components/layout/RootLayout.astro +79 -4
  60. package/src/components/layout/Search.astro +5 -5
  61. package/src/components/layout/nav-utils.ts +9 -4
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/core/builtin-tags.ts +1 -3
  65. package/src/core/config.ts +5 -28
  66. package/src/core/data.ts +6 -2
  67. package/src/core/graph.ts +8 -6
  68. package/src/core/i18n-ui.ts +5 -0
  69. package/src/core/links.ts +5 -19
  70. package/src/core/meta.ts +1 -1
  71. package/src/core/nav-diagnostics.ts +7 -0
  72. package/src/core/navigation.ts +38 -17
  73. package/src/core/project-graph.ts +0 -5
  74. package/src/core/schema.ts +133 -95
  75. package/src/core/sources/filesystem.ts +5 -1
  76. package/src/core/sources/resolve.ts +0 -13
  77. package/src/core/sources/watch.ts +43 -11
  78. package/src/core/types.ts +8 -6
  79. package/src/deploy/robots.ts +37 -4
  80. package/src/openapi/parse.ts +197 -14
  81. package/src/openapi/render-mdx.ts +44 -10
  82. package/src/openapi/scalar.ts +1 -1
  83. package/src/openapi/source.ts +19 -2
  84. package/src/search/documents.ts +9 -2
  85. package/src/theme/entry.ts +45 -17
  86. package/src/theme/icons.ts +18 -109
  87. package/src/theme/palette.ts +25 -51
  88. package/src/theme/twoslash.ts +6 -1
  89. package/dist/types/core/bridge.d.ts +0 -24
  90. package/dist/types/core/package-json.d.ts +0 -12
  91. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  92. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  93. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  94. package/dist/types/migrate/shared.d.ts +0 -153
  95. package/docs/advanced/bridge.mdx +0 -76
  96. package/docs/advanced/migrate.mdx +0 -124
  97. package/src/astro/static-assets.ts +0 -124
  98. package/src/cli/commands/migrate.ts +0 -39
  99. package/src/components/content/ApiField.astro +0 -75
  100. package/src/components/content/ParamField.astro +0 -39
  101. package/src/components/content/RequestField.astro +0 -23
  102. package/src/components/content/ResponseField.astro +0 -23
  103. package/src/components/content/Warning.astro +0 -9
  104. package/src/core/assets.ts +0 -31
  105. package/src/core/bridge.ts +0 -102
  106. package/src/core/sources/mintlify.ts +0 -190
  107. package/src/migrate/fumadocs/config.ts +0 -155
  108. package/src/migrate/fumadocs/content.ts +0 -376
  109. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  110. package/src/migrate/fumadocs/groups.ts +0 -237
  111. package/src/migrate/fumadocs/index.ts +0 -355
  112. package/src/migrate/fumadocs/meta.ts +0 -244
  113. package/src/migrate/migrate.ts +0 -53
  114. package/src/migrate/mintlify/assets.ts +0 -46
  115. package/src/migrate/mintlify/config.ts +0 -954
  116. package/src/migrate/mintlify/content.ts +0 -120
  117. package/src/migrate/mintlify/frontmatter.ts +0 -126
  118. package/src/migrate/mintlify/i18n.ts +0 -51
  119. package/src/migrate/mintlify/icons.ts +0 -128
  120. package/src/migrate/mintlify/index.ts +0 -459
  121. package/src/migrate/mintlify/snippets.ts +0 -315
  122. package/src/migrate/mintlify/transform.ts +0 -82
  123. package/src/migrate/nextra/content.ts +0 -46
  124. package/src/migrate/nextra/frontmatter.ts +0 -40
  125. package/src/migrate/nextra/index.ts +0 -389
  126. package/src/migrate/nextra/meta.ts +0 -266
  127. package/src/migrate/shared.ts +0 -801
  128. package/src/migrate/starlight/config.ts +0 -455
  129. package/src/migrate/starlight/content.ts +0 -75
  130. package/src/migrate/starlight/frontmatter.ts +0 -111
  131. package/src/migrate/starlight/i18n.ts +0 -54
  132. package/src/migrate/starlight/index.ts +0 -131
@@ -161,9 +161,14 @@ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
161
161
  * Adapters / API / AI tabs) drills each tab into its own pages instead of one
162
162
  * global tree, the way Fumadocs' root folders do. On a route under no tab (or
163
163
  * the root `/` tab), the tab-owned groups are hidden so the root sidebar shows
164
- * only pages that don't belong to a tab. Falls back to the full sidebar when a
165
- * matched tab maps to no group, or when hiding the tab sections would blank the
166
- * sidebar, so a route is never left empty.
164
+ * only pages that don't belong to a tab.
165
+ *
166
+ * When a matched tab owns no sidebar group a standalone page like the
167
+ * generated changelog timeline (`/changelog`), or a tab whose source produced
168
+ * no pages — the sidebar is empty. It must not fall back to the full tree: that
169
+ * would leak every *other* tab's section (e.g. the OpenAPI operations) onto the
170
+ * page. On a route under no tab, hiding the tab sections falls back to the full
171
+ * sidebar only when it would otherwise blank, so an un-tabbed route stays full.
167
172
  */
168
173
  export const sidebarForRoute = (
169
174
  sidebar: NavNode[],
@@ -172,7 +177,7 @@ export const sidebarForRoute = (
172
177
  ): NavNode[] => {
173
178
  const tab = activeTab(tabs, route);
174
179
  if (tab) {
175
- return sectionChildren(sidebar, tab.path) ?? sidebar;
180
+ return sectionChildren(sidebar, tab.path) ?? [];
176
181
  }
177
182
  const scoped = withoutTabSections(sidebar, tabs);
178
183
  return scoped.length > 0 ? scoped : sidebar;
@@ -1,28 +1,18 @@
1
1
  ---
2
2
  import specs from "blume:openapi";
3
- import MethodBadge from "./MethodBadge.astro";
4
3
 
4
+ // The spec-level metadata block (version + base URLs) at the top of an API
5
+ // overview page. The tag sections that follow are emitted by `overviewMdx` as
6
+ // markdown headings plus `<ApiTagOperations>` lists, so they land in the
7
+ // table of contents.
5
8
  interface Props {
6
9
  source: string;
7
10
  }
8
11
 
9
12
  const { source } = Astro.props;
10
13
  const spec = specs[source];
11
- const operations = Object.values(spec?.operations ?? {});
12
14
  const servers =
13
15
  ((spec?.document ?? {}) as { servers?: { url?: string }[] }).servers ?? [];
14
-
15
- // Tags in declared order, then any operation tag not listed under `tags`.
16
- const declaredSlugs = new Set(spec?.tags.map((tag) => tag.slug));
17
- const extraSlugs = new Set<string>();
18
- const extraTags: { slug: string; name: string; description: string }[] = [];
19
- for (const operation of operations) {
20
- if (!(declaredSlugs.has(operation.tagSlug) || extraSlugs.has(operation.tagSlug))) {
21
- extraSlugs.add(operation.tagSlug);
22
- extraTags.push({ description: "", name: operation.tag, slug: operation.tagSlug });
23
- }
24
- }
25
- const sections = [...(spec?.tags ?? []), ...extraTags];
26
16
  ---
27
17
 
28
18
  {
@@ -43,42 +33,6 @@ const sections = [...(spec?.tags ?? []), ...extraTags];
43
33
  ))}
44
34
  </div>
45
35
  )}
46
- {sections.map((tag) => {
47
- const ops = operations.filter(
48
- (operation) => operation.tagSlug === tag.slug
49
- );
50
- if (ops.length === 0) {
51
- return null;
52
- }
53
- return (
54
- <section class="mb-8">
55
- <h2 class="mb-1 font-semibold text-foreground text-xl">
56
- {tag.name}
57
- </h2>
58
- {tag.description && (
59
- <div class="mb-3 text-muted-foreground text-sm" set:text={tag.description} />
60
- )}
61
- <ul class="not-prose flex list-none flex-col gap-2 p-0">
62
- {ops.map((operation) => (
63
- <li>
64
- <a
65
- class="flex items-center gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
66
- href={operation.route}
67
- >
68
- <MethodBadge method={operation.method} />
69
- <span class="font-medium text-foreground text-sm">
70
- {operation.summary || operation.path}
71
- </span>
72
- <code class="ml-auto hidden text-muted-foreground text-xs sm:inline">
73
- {operation.path}
74
- </code>
75
- </a>
76
- </li>
77
- ))}
78
- </ul>
79
- </section>
80
- );
81
- })}
82
36
  </div>
83
37
  )
84
38
  }
@@ -0,0 +1,42 @@
1
+ ---
2
+ import specs from "blume:openapi";
3
+ import MethodBadge from "./MethodBadge.astro";
4
+
5
+ // The operation-link list for one tag section of an API overview page. The
6
+ // section heading itself is emitted by `overviewMdx` as a markdown `##` — so it
7
+ // flows into the table of contents — and this component renders only the list
8
+ // of operations beneath it.
9
+ interface Props {
10
+ source: string;
11
+ /** Tag slug (`ApiOperationRef.tagSlug`) selecting this section's operations. */
12
+ tag: string;
13
+ }
14
+
15
+ const { source, tag } = Astro.props;
16
+ const operations = Object.values(specs[source]?.operations ?? {}).filter(
17
+ (operation) => operation.tagSlug === tag
18
+ );
19
+ ---
20
+
21
+ {
22
+ operations.length > 0 && (
23
+ <ul class="not-prose my-4 flex list-none flex-col gap-2 p-0">
24
+ {operations.map((operation) => (
25
+ <li>
26
+ <a
27
+ class="flex items-center gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
28
+ href={operation.route}
29
+ >
30
+ <MethodBadge method={operation.method} />
31
+ <span class="font-medium text-foreground text-sm">
32
+ {operation.summary || operation.path}
33
+ </span>
34
+ <code class="ml-auto hidden text-muted-foreground text-xs sm:inline">
35
+ {operation.path}
36
+ </code>
37
+ </a>
38
+ </li>
39
+ ))}
40
+ </ul>
41
+ )
42
+ }
@@ -8,6 +8,7 @@ export const BUILTIN_MDX_TAGS = new Set<string>([
8
8
  "Accordion",
9
9
  "AccordionItem",
10
10
  "ApiOverview",
11
+ "ApiTagOperations",
11
12
  "AutoTypeTable",
12
13
  "Badge",
13
14
  "Callout",
@@ -28,10 +29,7 @@ export const BUILTIN_MDX_TAGS = new Set<string>([
28
29
  "Math",
29
30
  "Operation",
30
31
  "Panel",
31
- "ParamField",
32
32
  "Prompt",
33
- "RequestField",
34
- "ResponseField",
35
33
  "Step",
36
34
  "Steps",
37
35
  "Tab",
@@ -1,7 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
- import { detectMintlifyBridge } from "./bridge.ts";
4
- import type { BridgeDetection } from "./bridge.ts";
5
3
  import { applyDeploymentEnv } from "./deployment-env.ts";
6
4
  import { BlumeError, diagnosticsFromZod } from "./diagnostics.ts";
7
5
  import { createModuleLoader } from "./load-module.ts";
@@ -16,20 +14,11 @@ import type { Diagnostic } from "./types.ts";
16
14
  */
17
15
  export const defineConfig = (config: BlumeConfig): BlumeConfig => config;
18
16
 
19
- /** Bridge mode info: a foreign docs tool Blume is serving without migrating. */
20
- export interface ConfigBridge {
21
- tool: "mintlify";
22
- /** Absolute path of the foreign config file (`docs.json`/`mint.json`). */
23
- configFile: string;
24
- }
25
-
26
17
  /** Result of loading + validating a project config. */
27
18
  export interface ConfigLoadResult {
28
19
  config: ResolvedConfig;
29
20
  /** Absolute path of the config file used, or null when defaults were used. */
30
21
  configFile: string | null;
31
- /** Set when a foreign docs config (e.g. Mintlify) is being bridged. */
32
- bridge: ConfigBridge | null;
33
22
  diagnostics: Diagnostic[];
34
23
  }
35
24
 
@@ -50,9 +39,6 @@ export const loadConfig = async (
50
39
  ): Promise<ConfigLoadResult> => {
51
40
  const configFile = findConfigFile(root);
52
41
 
53
- // With no Blume config, a Mintlify `docs.json` activates bridge mode: serve
54
- // the unconverted project by synthesizing config + a `mintlify` content source.
55
- let bridge: BridgeDetection | null = null;
56
42
  let raw: unknown = {};
57
43
  if (configFile) {
58
44
  try {
@@ -65,30 +51,24 @@ export const loadConfig = async (
65
51
  severity: "error",
66
52
  });
67
53
  }
68
- } else {
69
- bridge = await detectMintlifyBridge(root);
70
- if (bridge) {
71
- ({ raw } = bridge);
72
- }
73
54
  }
74
55
 
75
- const sourceFile = bridge?.configFile ?? configFile;
76
56
  const parsed = blumeConfigSchema.safeParse(raw ?? {});
77
57
  if (!parsed.success) {
78
58
  // Read the raw config text (when on disk) so errors carry a line/column.
79
59
  const source =
80
- sourceFile && existsSync(sourceFile)
81
- ? readFileSync(sourceFile, "utf-8")
60
+ configFile && existsSync(configFile)
61
+ ? readFileSync(configFile, "utf-8")
82
62
  : undefined;
83
63
  const diagnostics = diagnosticsFromZod(parsed.error, {
84
64
  code: "BLUME_CONFIG_INVALID",
85
- file: sourceFile ?? undefined,
65
+ file: configFile ?? undefined,
86
66
  source,
87
67
  });
88
68
  const [first, ...rest] = diagnostics;
89
69
  const primary = first ?? {
90
70
  code: "BLUME_CONFIG_INVALID",
91
- file: sourceFile ?? undefined,
71
+ file: configFile ?? undefined,
92
72
  message: "Invalid Blume config.",
93
73
  severity: "error" as const,
94
74
  };
@@ -115,15 +95,12 @@ export const loadConfig = async (
115
95
  const ogEnabled = config.seo.og.enabled ?? Boolean(site);
116
96
 
117
97
  return {
118
- bridge: bridge
119
- ? { configFile: bridge.configFile, tool: bridge.tool }
120
- : null,
121
98
  config: {
122
99
  ...config,
123
100
  deployment: { ...config.deployment, site },
124
101
  seo: { ...config.seo, og: { ...config.seo.og, enabled: ogEnabled } },
125
102
  },
126
- configFile: sourceFile,
103
+ configFile,
127
104
  diagnostics: [],
128
105
  };
129
106
  };
package/src/core/data.ts CHANGED
@@ -17,6 +17,8 @@ export interface BlumeLogo {
17
17
  dark?: string;
18
18
  alt: string;
19
19
  href: string;
20
+ /** Wordmark text beside the mark; `undefined` falls back to the site title. */
21
+ text?: string;
20
22
  }
21
23
 
22
24
  /** A favicon or apple-touch-icon: a link href plus an optional MIME type. */
@@ -86,6 +88,10 @@ export interface BlumeDataConfig {
86
88
  analytics: NonNullable<ResolvedConfig["analytics"]> | null;
87
89
  /** Apple touch icon, or `null` when none is configured/detected. */
88
90
  appleIcon: BlumeFavicon | null;
91
+ /** Ask AI empty-state suggestions, or `null` when Ask AI is off. */
92
+ ask: {
93
+ suggestions: NonNullable<ResolvedConfig["ai"]["ask"]>["suggestions"];
94
+ } | null;
89
95
  banner: BlumeBanner | null;
90
96
  /** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
91
97
  codeWrap: boolean;
@@ -93,8 +99,6 @@ export interface BlumeDataConfig {
93
99
  favicon: BlumeFavicon;
94
100
  feedback: boolean;
95
101
  i18n: BlumeDataI18n | null;
96
- /** Default icon library for bare `icon` names. */
97
- icons: ResolvedConfig["icons"];
98
102
  /** `markdown.imageZoom`: click-to-zoom content images. */
99
103
  imageZoom: boolean;
100
104
  logo: BlumeLogo | null;
package/src/core/graph.ts CHANGED
@@ -60,7 +60,7 @@ export const buildContentGraph = (
60
60
  }
61
61
 
62
62
  // Each locale gets an independent tree from its own pages and folder meta,
63
- // so navigation may diverge per language (Mintlify-style).
63
+ // so navigation may diverge per language.
64
64
  for (const { code } of i18n.locales) {
65
65
  // Localize internal tab paths so a header tab points to its in-locale
66
66
  // route (e.g. `/docs` -> `/fr/docs`); external paths pass through.
@@ -89,7 +89,8 @@ export const buildContentGraph = (
89
89
  }
90
90
 
91
91
  navigationByLocale[code] = buildNavigation(localePages, {
92
- chromeVariants: options.navigation.chromeVariants,
92
+ display: options.navigation.sidebar.display,
93
+ featured: options.navigation.featured,
93
94
  folderMeta: options.folderMeta,
94
95
  // Meta files live in locale directories only under the `dir` parser
95
96
  // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations
@@ -100,23 +101,24 @@ export const buildContentGraph = (
100
101
  refByLogical: true,
101
102
  selectors: options.navigation.selectors,
102
103
  sharedFolderMeta: options.sharedFolderMeta,
103
- sidebar: options.navigation.sidebar,
104
+ sidebar: options.navigation.sidebar.items,
104
105
  tabs,
105
106
  });
106
107
  }
107
108
  navigation = navigationByLocale[i18n.defaultLocale] ?? {
108
- chromeVariants: [],
109
+ featured: [],
109
110
  selectors: [],
110
111
  sidebar: [],
111
112
  tabs: [],
112
113
  };
113
114
  } else {
114
115
  navigation = buildNavigation(pages, {
115
- chromeVariants: options.navigation.chromeVariants,
116
+ display: options.navigation.sidebar.display,
117
+ featured: options.navigation.featured,
116
118
  folderMeta: options.folderMeta,
117
119
  selectors: options.navigation.selectors,
118
120
  sharedFolderMeta: options.sharedFolderMeta,
119
- sidebar: options.navigation.sidebar,
121
+ sidebar: options.navigation.sidebar.items,
120
122
  tabs: options.navigation.tabs,
121
123
  });
122
124
  }
@@ -19,6 +19,7 @@ const uiStringsObject = z.object({
19
19
  connectMcp: z.string().default("Connect to MCP"),
20
20
  copied: z.string().default("Copied!"),
21
21
  copyClaudeCode: z.string().default("Copy Claude Code command"),
22
+ copyCodex: z.string().default("Copy Codex command"),
22
23
  copyMarkdown: z.string().default("Copy as Markdown"),
23
24
  copyServerUrl: z.string().default("Copy server URL"),
24
25
  edit: z.string().default("Edit on GitHub"),
@@ -28,11 +29,15 @@ const uiStringsObject = z.object({
28
29
  .default({}),
29
30
  ask: z
30
31
  .object({
32
+ clear: z.string().default("Clear conversation"),
33
+ close: z.string().default("Close"),
34
+ copy: z.string().default("Copy conversation"),
31
35
  empty: z.string().default("Ask a question about the docs."),
32
36
  error: z.string().default("Sorry, something went wrong."),
33
37
  label: z.string().default("Ask a question"),
34
38
  placeholder: z.string().default("Ask a question…"),
35
39
  send: z.string().default("Send"),
40
+ tip: z.string().default("Tip: You can open and close chat with"),
36
41
  title: z.string().default("Ask AI"),
37
42
  })
38
43
  .default({}),
package/src/core/links.ts CHANGED
@@ -2,7 +2,6 @@ import { existsSync } from "node:fs";
2
2
 
3
3
  import { basename, join } from "pathe";
4
4
 
5
- import type { AssetMount } from "./assets.ts";
6
5
  import type {
7
6
  ContentGraph,
8
7
  Diagnostic,
@@ -38,25 +37,15 @@ interface ExternalRef extends LinkSite {
38
37
  /** Lookups derived once from the content graph. */
39
38
  interface LinkContext {
40
39
  anchors: Map<string, Set<string>>;
41
- /** `content.assets` mounts served alongside `public/` (checked in place). */
42
- assetMounts: AssetMount[];
43
40
  publicDir: string | null;
44
41
  /** Normalized `redirect.from` paths — valid targets that resolve at runtime. */
45
42
  redirects: Set<string>;
46
43
  routes: Set<string>;
47
44
  }
48
45
 
49
- /** Whether a resolved asset path exists under `public/` or an asset mount. */
50
- const assetIsPresent = (resolved: string, ctx: LinkContext): boolean => {
51
- if (ctx.publicDir && existsSync(join(ctx.publicDir, resolved))) {
52
- return true;
53
- }
54
- return ctx.assetMounts.some(
55
- (mount) =>
56
- (resolved === mount.url || resolved.startsWith(`${mount.url}/`)) &&
57
- existsSync(join(mount.dir, resolved.slice(mount.url.length)))
58
- );
59
- };
46
+ /** Whether a resolved asset path exists under `public/`. */
47
+ const assetIsPresent = (resolved: string, ctx: LinkContext): boolean =>
48
+ ctx.publicDir !== null && existsSync(join(ctx.publicDir, resolved));
60
49
 
61
50
  /** Outcome of classifying one link target. */
62
51
  type LinkResult = Diagnostic | "asset-unchecked" | null;
@@ -165,8 +154,8 @@ const checkPathLink = (
165
154
  if (assetIsPresent(resolved, ctx)) {
166
155
  return null;
167
156
  }
168
- // Nowhere to look: no `public/` and no asset mounts configured.
169
- if (ctx.publicDir === null && ctx.assetMounts.length === 0) {
157
+ // Nowhere to look: no `public/` directory.
158
+ if (ctx.publicDir === null) {
170
159
  return "asset-unchecked";
171
160
  }
172
161
  return {
@@ -347,15 +336,12 @@ export const validateLinks = async (
347
336
  options: {
348
337
  publicDir: string | null;
349
338
  checkExternal?: boolean;
350
- /** `content.assets` mounts served alongside `public/`. */
351
- assetMounts?: AssetMount[];
352
339
  /** Configured redirects; their `from` paths count as valid link targets. */
353
340
  redirects?: { from: string }[];
354
341
  }
355
342
  ): Promise<Diagnostic[]> => {
356
343
  const ctx: LinkContext = {
357
344
  anchors: buildAnchorIndex(graph.pages),
358
- assetMounts: options.assetMounts ?? [],
359
345
  publicDir: options.publicDir,
360
346
  redirects: new Set(
361
347
  (options.redirects ?? []).map((redirect) => toRoute(redirect.from))
package/src/core/meta.ts CHANGED
@@ -39,7 +39,7 @@ export const discoverFolderMeta = async (
39
39
  absolute: true,
40
40
  cwd: contentRoot,
41
41
  // Never descend into dependencies or build output — relevant when the
42
- // content root is the project root (e.g. a migrated Mintlify project).
42
+ // content root is the project root (e.g. a `.`-rooted or all-staged project).
43
43
  ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
44
44
  onlyFiles: true,
45
45
  });
@@ -42,6 +42,9 @@ const collectIcons = (
42
42
  push(item.icon, `selector "${item.label}"`);
43
43
  }
44
44
  }
45
+ for (const link of navigation.featured) {
46
+ push(link.icon, `featured link "${link.label}"`);
47
+ }
45
48
  const sidebars = [navigation.sidebar];
46
49
  for (const sidebar of sidebars) {
47
50
  for (const node of flattenNodes(sidebar)) {
@@ -90,6 +93,10 @@ export const validateNavTargets = (
90
93
  ...navigation.selectors.flatMap((selector) =>
91
94
  selector.items.map((item) => ({ label: item.label, path: item.path }))
92
95
  ),
96
+ ...navigation.featured.map((link) => ({
97
+ label: link.label,
98
+ path: link.href,
99
+ })),
93
100
  ];
94
101
  const diagnostics: Diagnostic[] = [];
95
102
  const seen = new Set<string>();
@@ -6,7 +6,7 @@ import type {
6
6
  SidebarItemConfig,
7
7
  } from "./schema.ts";
8
8
  import type {
9
- NavChromeVariant,
9
+ FeaturedLink,
10
10
  NavNode,
11
11
  Navigation,
12
12
  NavSelector,
@@ -59,7 +59,6 @@ interface MutableGroup {
59
59
  label: string;
60
60
  icon?: string;
61
61
  collapsed?: boolean;
62
- display?: SidebarDisplay;
63
62
  order: number;
64
63
  children: MutableNode[];
65
64
  index: Map<string, MutableGroup>;
@@ -141,7 +140,6 @@ const applyFolderMeta = (
141
140
  group.icon = meta.icon ?? group.icon;
142
141
  group.order = meta.order ?? group.order;
143
142
  group.collapsed = meta.collapsed ?? group.collapsed;
144
- group.display = meta.display ?? group.display;
145
143
 
146
144
  if (meta.pages) {
147
145
  const rank = new Map(meta.pages.map((key, i) => [key, i]));
@@ -175,7 +173,21 @@ const sortNodes = (nodes: MutableNode[]): void => {
175
173
  }
176
174
  };
177
175
 
178
- const toNavNode = (node: MutableNode): NavNode => {
176
+ /**
177
+ * In flat display a group renders as a plain section header, so a loose page
178
+ * sorted after a group would visually read as that group's last child. Hoist
179
+ * pages above groups at every level (relative order otherwise preserved).
180
+ */
181
+ const hoistPages = (nodes: MutableNode[]): void => {
182
+ const pages = nodes.filter((node) => node.kind === "page");
183
+ const groups = nodes.filter((node) => node.kind === "group");
184
+ nodes.splice(0, nodes.length, ...pages, ...groups);
185
+ for (const group of groups) {
186
+ hoistPages(group.children);
187
+ }
188
+ };
189
+
190
+ const toNavNode = (node: MutableNode, display: SidebarDisplay): NavNode => {
179
191
  if (node.kind === "page") {
180
192
  return {
181
193
  badge: node.badge,
@@ -189,9 +201,9 @@ const toNavNode = (node: MutableNode): NavNode => {
189
201
  };
190
202
  }
191
203
  return {
192
- children: node.children.map(toNavNode),
204
+ children: node.children.map((child) => toNavNode(child, display)),
193
205
  collapsed: node.collapsed,
194
- display: node.display,
206
+ display,
195
207
  icon: node.icon,
196
208
  kind: "group",
197
209
  label: node.label,
@@ -204,7 +216,8 @@ const buildFileSystemSidebar = (
204
216
  pages: PageRecord[],
205
217
  folderMeta: Map<string, FolderMeta>,
206
218
  sharedMeta: Map<string, FolderMeta>,
207
- metaPrefix: string
219
+ metaPrefix: string,
220
+ display: SidebarDisplay
208
221
  ): NavNode[] => {
209
222
  const root = createGroup("", "", "", 0);
210
223
 
@@ -247,7 +260,10 @@ const buildFileSystemSidebar = (
247
260
 
248
261
  applyFolderMeta(root, folderMeta, sharedMeta, metaPrefix);
249
262
  sortNodes(root.children);
250
- return root.children.map(toNavNode);
263
+ if (display === "flat") {
264
+ hoistPages(root.children);
265
+ }
266
+ return root.children.map((child) => toNavNode(child, display));
251
267
  };
252
268
 
253
269
  const normalizeRef = (ref: string): string => {
@@ -276,7 +292,8 @@ const routeForRef = (
276
292
  /** Build the sidebar tree from an explicit config spec. */
277
293
  const buildConfigSidebar = (
278
294
  items: SidebarItemConfig[],
279
- byRoute: Map<string, PageRecord>
295
+ byRoute: Map<string, PageRecord>,
296
+ display: SidebarDisplay
280
297
  ): NavNode[] => {
281
298
  const nodes: NavNode[] = [];
282
299
 
@@ -301,10 +318,10 @@ const buildConfigSidebar = (
301
318
  if (item.items) {
302
319
  nodes.push({
303
320
  badge: item.badge,
304
- children: buildConfigSidebar(item.items, byRoute),
321
+ children: buildConfigSidebar(item.items, byRoute, display),
305
322
  collapsed: item.collapsed,
306
323
  directory: item.directory,
307
- display: item.display,
324
+ display: item.display ?? display,
308
325
  icon: item.icon,
309
326
  kind: "group",
310
327
  label: item.label,
@@ -346,8 +363,10 @@ const buildConfigSidebar = (
346
363
  export const buildNavigation = (
347
364
  pages: PageRecord[],
348
365
  options: {
349
- chromeVariants?: NavChromeVariant[];
350
366
  folderMeta: Map<string, FolderMeta>;
367
+ /** Global display mode for every sidebar group (default `flat`). */
368
+ display?: SidebarDisplay;
369
+ featured?: FeaturedLink[];
351
370
  selectors?: NavSelector[];
352
371
  tabs?: NavTab[];
353
372
  sidebar?: SidebarItemConfig[];
@@ -363,9 +382,10 @@ export const buildNavigation = (
363
382
  sharedFolderMeta?: Map<string, FolderMeta>;
364
383
  }
365
384
  ): Navigation => {
366
- const chromeVariants = options.chromeVariants ?? [];
385
+ const featured = options.featured ?? [];
367
386
  const selectors = options.selectors ?? [];
368
387
  const tabs = options.tabs ?? [];
388
+ const display = options.display ?? "flat";
369
389
  const metaPrefix = options.metaPrefix ?? "";
370
390
  const sharedFolderMeta = options.sharedFolderMeta ?? new Map();
371
391
  const byRoute = new Map(
@@ -377,21 +397,22 @@ export const buildNavigation = (
377
397
 
378
398
  if (options.sidebar) {
379
399
  return {
380
- chromeVariants,
400
+ featured,
381
401
  selectors,
382
- sidebar: buildConfigSidebar(options.sidebar, byRoute),
402
+ sidebar: buildConfigSidebar(options.sidebar, byRoute, display),
383
403
  tabs,
384
404
  };
385
405
  }
386
406
 
387
407
  return {
388
- chromeVariants,
408
+ featured,
389
409
  selectors,
390
410
  sidebar: buildFileSystemSidebar(
391
411
  pages,
392
412
  options.folderMeta,
393
413
  sharedFolderMeta,
394
- metaPrefix
414
+ metaPrefix,
415
+ display
395
416
  ),
396
417
  tabs,
397
418
  };
@@ -1,5 +1,4 @@
1
1
  import { loadConfig } from "./config.ts";
2
- import type { ConfigBridge } from "./config.ts";
3
2
  import { buildContentGraph } from "./graph.ts";
4
3
  import { i18nDiagnostics } from "./i18n.ts";
5
4
  import {
@@ -69,8 +68,6 @@ export interface BlumeProject {
69
68
  diagnostics: Diagnostic[];
70
69
  /** The instantiated content sources, for lazy entry reads (search/AI/raw). */
71
70
  sources: ContentSource[];
72
- /** Set when serving a foreign docs config (e.g. Mintlify) without migrating. */
73
- bridge: ConfigBridge | null;
74
71
  }
75
72
 
76
73
  /**
@@ -97,7 +94,6 @@ export const scanProject = async (
97
94
  const configResult = await loadConfig(root, {
98
95
  devServerUrl: options.devServerUrl,
99
96
  });
100
- const { bridge } = configResult;
101
97
  const config = applyConfigOverrides(configResult.config, options.overrides);
102
98
  const context = resolveProjectContext(root, config, {
103
99
  runtimeDir: options.runtimeDir,
@@ -179,7 +175,6 @@ export const scanProject = async (
179
175
  const i18nWarnings = config.i18n ? i18nDiagnostics(pages, config.i18n) : [];
180
176
 
181
177
  return {
182
- bridge,
183
178
  config,
184
179
  context,
185
180
  diagnostics: [