blume 0.5.2 → 0.5.4

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 (137) hide show
  1. package/dist/cli/index.js +2235 -5942
  2. package/dist/cli/index.js.map +36 -65
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +2 -2
  5. package/dist/types/core/schema.d.ts +58 -453
  6. package/dist/types/core/types.d.ts +1 -7
  7. package/docs/advanced/meta.ts +1 -8
  8. package/docs/advanced/skills.mdx +28 -0
  9. package/docs/configuration/ai.mdx +3 -1
  10. package/docs/configuration/index.mdx +13 -17
  11. package/docs/configuration/theming.mdx +3 -11
  12. package/docs/content/components.mdx +2 -53
  13. package/docs/content/i18n.mdx +1 -5
  14. package/docs/content/sources.mdx +1 -1
  15. package/docs/content/syntax.mdx +1 -1
  16. package/docs/index.mdx +0 -1
  17. package/docs/reference/cli.mdx +12 -13
  18. package/package.json +4 -6
  19. package/skills/blume/SKILL.md +71 -0
  20. package/skills/blume-update-docs/SKILL.md +52 -0
  21. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  22. package/src/ai/mcp/discovery.ts +3 -1
  23. package/src/ai/mcp/server.ts +3 -1
  24. package/src/astro/component-slots.ts +10 -2
  25. package/src/astro/generate.ts +15 -12
  26. package/src/astro/integration.ts +0 -21
  27. package/src/astro/templates.ts +62 -37
  28. package/src/cli/coalesce.ts +43 -0
  29. package/src/cli/commands/dev.ts +30 -22
  30. package/src/cli/commands/validate.ts +0 -2
  31. package/src/cli/dev-lock.ts +4 -2
  32. package/src/cli/index.ts +0 -2
  33. package/src/components/BlumePage.astro +0 -6
  34. package/src/components/Icon.astro +1 -12
  35. package/src/components/content/AccordionItem.astro +3 -6
  36. package/src/components/content/Badge.astro +1 -3
  37. package/src/components/content/Callout.astro +3 -9
  38. package/src/components/content/Card.astro +2 -3
  39. package/src/components/content/ColorItem.astro +8 -5
  40. package/src/components/content/Column.astro +1 -1
  41. package/src/components/content/Prompt.astro +8 -4
  42. package/src/components/content/Step.astro +3 -4
  43. package/src/components/content/Tab.astro +2 -3
  44. package/src/components/content/Tabs.astro +13 -2
  45. package/src/components/content/TypeTable.astro +5 -5
  46. package/src/components/content/Update.astro +1 -1
  47. package/src/components/content/mermaid-element.ts +20 -2
  48. package/src/components/islands/ask-ai.tsx +4 -8
  49. package/src/components/islands/base-path.ts +30 -0
  50. package/src/components/islands/hooks.ts +12 -8
  51. package/src/components/layout/Header.astro +26 -3
  52. package/src/components/layout/Logo.astro +5 -1
  53. package/src/components/layout/NavSelector.astro +1 -1
  54. package/src/components/layout/NavTree.astro +14 -14
  55. package/src/components/layout/PageActions.astro +17 -11
  56. package/src/components/layout/PageLayout.astro +42 -0
  57. package/src/components/layout/ReferenceLayout.astro +1 -0
  58. package/src/components/layout/RootLayout.astro +31 -2
  59. package/src/components/layout/Search.astro +9 -6
  60. package/src/components/layout/nav-utils.ts +9 -4
  61. package/src/components/layout/search/types.ts +16 -5
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/components/openapi/ParametersTable.astro +1 -1
  65. package/src/components/openapi/SchemaProperty.astro +1 -1
  66. package/src/components/openapi/SchemaTable.astro +3 -3
  67. package/src/components/openapi/helpers.ts +17 -8
  68. package/src/components/openapi/snippets.ts +17 -4
  69. package/src/core/builtin-tags.ts +1 -3
  70. package/src/core/config.ts +19 -33
  71. package/src/core/data.ts +2 -2
  72. package/src/core/graph.ts +7 -5
  73. package/src/core/links.ts +5 -19
  74. package/src/core/meta.ts +1 -1
  75. package/src/core/navigation.ts +5 -6
  76. package/src/core/project-graph.ts +0 -5
  77. package/src/core/schema.ts +13 -90
  78. package/src/core/sources/filesystem.ts +19 -1
  79. package/src/core/sources/mdx-remote.ts +20 -4
  80. package/src/core/sources/normalize.ts +28 -6
  81. package/src/core/sources/resolve.ts +0 -13
  82. package/src/core/sources/watch.ts +45 -0
  83. package/src/core/types.ts +1 -8
  84. package/src/markdown/code-title.ts +6 -3
  85. package/src/markdown/package-install.ts +3 -1
  86. package/src/og/card.ts +16 -4
  87. package/src/openapi/parse.ts +197 -14
  88. package/src/openapi/render-mdx.ts +54 -11
  89. package/src/openapi/source.ts +19 -2
  90. package/src/search/sync/orama-cloud.ts +2 -0
  91. package/src/search/sync/typesense.ts +4 -0
  92. package/src/theme/entry.ts +45 -17
  93. package/src/theme/icons.ts +22 -104
  94. package/src/theme/palette.ts +35 -47
  95. package/src/theme/twoslash.ts +6 -1
  96. package/dist/types/core/bridge.d.ts +0 -24
  97. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  98. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  99. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  100. package/docs/advanced/bridge.mdx +0 -76
  101. package/docs/advanced/migrate.mdx +0 -123
  102. package/src/astro/static-assets.ts +0 -117
  103. package/src/cli/commands/migrate.ts +0 -39
  104. package/src/components/content/ApiField.astro +0 -75
  105. package/src/components/content/ParamField.astro +0 -39
  106. package/src/components/content/RequestField.astro +0 -23
  107. package/src/components/content/ResponseField.astro +0 -23
  108. package/src/components/content/Warning.astro +0 -9
  109. package/src/core/assets.ts +0 -31
  110. package/src/core/bridge.ts +0 -102
  111. package/src/core/sources/mintlify.ts +0 -204
  112. package/src/migrate/fumadocs/config.ts +0 -155
  113. package/src/migrate/fumadocs/content.ts +0 -378
  114. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  115. package/src/migrate/fumadocs/groups.ts +0 -237
  116. package/src/migrate/fumadocs/index.ts +0 -340
  117. package/src/migrate/fumadocs/meta.ts +0 -244
  118. package/src/migrate/migrate.ts +0 -53
  119. package/src/migrate/mintlify/assets.ts +0 -46
  120. package/src/migrate/mintlify/config.ts +0 -958
  121. package/src/migrate/mintlify/content.ts +0 -120
  122. package/src/migrate/mintlify/frontmatter.ts +0 -126
  123. package/src/migrate/mintlify/i18n.ts +0 -51
  124. package/src/migrate/mintlify/icons.ts +0 -128
  125. package/src/migrate/mintlify/index.ts +0 -372
  126. package/src/migrate/mintlify/snippets.ts +0 -306
  127. package/src/migrate/mintlify/transform.ts +0 -82
  128. package/src/migrate/nextra/content.ts +0 -46
  129. package/src/migrate/nextra/frontmatter.ts +0 -40
  130. package/src/migrate/nextra/index.ts +0 -374
  131. package/src/migrate/nextra/meta.ts +0 -266
  132. package/src/migrate/shared.ts +0 -728
  133. package/src/migrate/starlight/config.ts +0 -455
  134. package/src/migrate/starlight/content.ts +0 -78
  135. package/src/migrate/starlight/frontmatter.ts +0 -111
  136. package/src/migrate/starlight/i18n.ts +0 -54
  137. package/src/migrate/starlight/index.ts +0 -131
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,9 +89,13 @@ export const buildContentGraph = (
89
89
  }
90
90
 
91
91
  navigationByLocale[code] = buildNavigation(localePages, {
92
- chromeVariants: options.navigation.chromeVariants,
93
92
  folderMeta: options.folderMeta,
94
- metaPrefix: code === i18n.defaultLocale ? "" : code,
93
+ // Meta files live in locale directories only under the `dir` parser
94
+ // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations
95
+ // sit next to the originals and `guides/meta.ts` applies to every
96
+ // locale — prefixing would look up keys that can never exist.
97
+ metaPrefix:
98
+ i18n.parser === "dir" && code !== i18n.defaultLocale ? code : "",
95
99
  refByLogical: true,
96
100
  selectors: options.navigation.selectors,
97
101
  sharedFolderMeta: options.sharedFolderMeta,
@@ -100,14 +104,12 @@ export const buildContentGraph = (
100
104
  });
101
105
  }
102
106
  navigation = navigationByLocale[i18n.defaultLocale] ?? {
103
- chromeVariants: [],
104
107
  selectors: [],
105
108
  sidebar: [],
106
109
  tabs: [],
107
110
  };
108
111
  } else {
109
112
  navigation = buildNavigation(pages, {
110
- chromeVariants: options.navigation.chromeVariants,
111
113
  folderMeta: options.folderMeta,
112
114
  selectors: options.navigation.selectors,
113
115
  sharedFolderMeta: options.sharedFolderMeta,
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
  });
@@ -6,7 +6,6 @@ import type {
6
6
  SidebarItemConfig,
7
7
  } from "./schema.ts";
8
8
  import type {
9
- NavChromeVariant,
10
9
  NavNode,
11
10
  Navigation,
12
11
  NavSelector,
@@ -255,7 +254,11 @@ const normalizeRef = (ref: string): string => {
255
254
  return "/";
256
255
  }
257
256
  const withSlash = ref.startsWith("/") ? ref : `/${ref}`;
258
- return withSlash.endsWith("/index") ? withSlash.slice(0, -6) : withSlash;
257
+ const trimmed = withSlash.endsWith("/index")
258
+ ? withSlash.slice(0, -"/index".length)
259
+ : withSlash;
260
+ // "/index" trims to "" — that's the root, not an empty route.
261
+ return trimmed === "" ? "/" : trimmed;
259
262
  };
260
263
 
261
264
  const routeForRef = (
@@ -342,7 +345,6 @@ const buildConfigSidebar = (
342
345
  export const buildNavigation = (
343
346
  pages: PageRecord[],
344
347
  options: {
345
- chromeVariants?: NavChromeVariant[];
346
348
  folderMeta: Map<string, FolderMeta>;
347
349
  selectors?: NavSelector[];
348
350
  tabs?: NavTab[];
@@ -359,7 +361,6 @@ export const buildNavigation = (
359
361
  sharedFolderMeta?: Map<string, FolderMeta>;
360
362
  }
361
363
  ): Navigation => {
362
- const chromeVariants = options.chromeVariants ?? [];
363
364
  const selectors = options.selectors ?? [];
364
365
  const tabs = options.tabs ?? [];
365
366
  const metaPrefix = options.metaPrefix ?? "";
@@ -373,7 +374,6 @@ export const buildNavigation = (
373
374
 
374
375
  if (options.sidebar) {
375
376
  return {
376
- chromeVariants,
377
377
  selectors,
378
378
  sidebar: buildConfigSidebar(options.sidebar, byRoute),
379
379
  tabs,
@@ -381,7 +381,6 @@ export const buildNavigation = (
381
381
  }
382
382
 
383
383
  return {
384
- chromeVariants,
385
384
  selectors,
386
385
  sidebar: buildFileSystemSidebar(
387
386
  pages,
@@ -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: [
@@ -99,25 +99,15 @@ const pageMetaBaseSchema = z
99
99
  deprecated: z.boolean().default(false),
100
100
  description: z.string().optional(),
101
101
  draft: z.boolean().default(false),
102
- groups: z.union([z.string(), z.array(z.string())]).optional(),
103
102
  hidden: z.boolean().default(false),
104
- hideApiMarker: z.boolean().default(false),
105
- hideFooterPagination: z.boolean().optional(),
106
103
  icon: iconName.optional(),
107
- iconType: z.string().optional(),
108
- keywords: z.array(z.string()).optional(),
109
104
  /** Overrides the git-derived last-modified date when `lastModified` is on. */
110
105
  lastModified: dateSchema.optional(),
111
- mode: z.string().optional(),
112
106
  noindex: z.boolean().default(false),
113
- public: z.boolean().optional(),
114
- rss: z.boolean().optional(),
115
107
  search: searchMetaSchema.default({}),
116
108
  seo: seoMetaSchema.default({}),
117
109
  sidebar: sidebarMetaSchema.default({}),
118
- sidebarTitle: z.string().optional(),
119
110
  slug: z.string().optional(),
120
- tag: z.string().optional(),
121
111
  title: z.string().optional(),
122
112
  type: z.string().default("doc"),
123
113
  })
@@ -160,45 +150,41 @@ export type FolderMeta = z.infer<typeof folderMetaSchema>;
160
150
  // Project config (blume.config.ts)
161
151
  // ---------------------------------------------------------------------------
162
152
 
163
- const logoConfigSchema = z.union([
153
+ /** The logo mark: a single image path/URL, or light/dark variants with alt text. */
154
+ const logoImageSchema = z.union([
164
155
  z.string(),
165
156
  z
166
157
  .object({
167
158
  alt: z.string().optional(),
168
159
  dark: z.string().optional(),
169
- href: z.string().optional(),
170
160
  light: z.string().optional(),
171
161
  })
172
162
  .strict(),
173
163
  ]);
174
164
 
175
- const faviconConfigSchema = z.union([
165
+ /**
166
+ * Site logo. A bare string is the image shorthand. The object form splits the
167
+ * brand into an optional `image` mark and optional wordmark `text` so a site can
168
+ * show an image-only logo (a mark with the wordmark baked in), a text-only logo,
169
+ * or both. Omit `text` to fall back to the site title; set `text: ""` to render
170
+ * the mark alone. `href` overrides the brand link (defaults to `/`).
171
+ */
172
+ const logoConfigSchema = z.union([
176
173
  z.string(),
177
174
  z
178
175
  .object({
179
- dark: z.string().optional(),
180
- light: z.string().optional(),
176
+ href: z.string().optional(),
177
+ image: logoImageSchema.optional(),
178
+ text: z.string().optional(),
181
179
  })
182
180
  .strict(),
183
181
  ]);
184
182
 
185
- const bannerColorSchema = z
186
- .object({
187
- dark: z.string().optional(),
188
- light: z.string().optional(),
189
- })
190
- .strict()
191
- .refine((value) => value.dark !== undefined || value.light !== undefined, {
192
- message: "Banner color requires at least one of light or dark.",
193
- });
194
-
195
183
  /** Site-wide announcement banner: a string, or text with an optional link. */
196
184
  const bannerConfigSchema = z.union([
197
185
  z.string(),
198
186
  z
199
187
  .object({
200
- /** Background color override (Mintlify compatibility). */
201
- color: bannerColorSchema.optional(),
202
188
  content: z.string(),
203
189
  /** Show a dismiss button; the choice is remembered per visitor. */
204
190
  dismissible: z.boolean().default(false),
@@ -208,8 +194,6 @@ const bannerConfigSchema = z.union([
208
194
  .object({ href: z.string(), text: z.string() })
209
195
  .strict()
210
196
  .optional(),
211
- /** Tone (Mintlify compatibility). */
212
- type: z.enum(["info", "warning", "critical"]).optional(),
213
197
  })
214
198
  .strict(),
215
199
  ]);
@@ -329,30 +313,6 @@ const githubReleasesSourceSchema = z
329
313
  })
330
314
  .strict();
331
315
 
332
- /**
333
- * In-place Mintlify content (`docs.json` + MDX). Powers "bridge mode": Blume
334
- * reads an unconverted Mintlify project, transforming each page to Blume MDX at
335
- * scan time. Injected automatically by `loadConfig` when a `docs.json` is found
336
- * and no `blume.config.*` exists; can also be configured explicitly.
337
- */
338
- const mintlifySourceSchema = z
339
- .object({
340
- /** Absolute path of the `docs.json`/`mint.json`, watched for changes in dev. */
341
- configFile: z.string().optional(),
342
- /** Patterns excluded from page discovery (Mintlify defaults are merged in). */
343
- exclude: z.array(z.string()).default([]),
344
- /** Glob patterns for Mintlify content files. */
345
- include: z.array(z.string()).default(["**/*.{md,mdx}"]),
346
- /** Namespaces the source's routes under `/<prefix>/`. */
347
- prefix: z.string().optional(),
348
- /** Content root, absolute or relative to the project root (Mintlify: `.`). */
349
- root: z.string().default("."),
350
- type: z.literal("mintlify"),
351
- /** `docs.json` variables, inlined into content (`{{name}}`) at scan time. */
352
- variables: z.record(z.string(), z.string()).default({}),
353
- })
354
- .strict();
355
-
356
316
  /**
357
317
  * A user-provided `ContentSource` instance, passed straight through from
358
318
  * `blume.config.ts`. This is the extension point that lets adapters with custom
@@ -377,7 +337,6 @@ const contentSourceSchema = z.discriminatedUnion("type", [
377
337
  githubReleasesSourceSchema,
378
338
  sanitySourceSchema,
379
339
  notionSourceSchema,
380
- mintlifySourceSchema,
381
340
  customSourceSchema,
382
341
  ]);
383
342
 
@@ -386,13 +345,6 @@ export type ContentSourceConfig = z.infer<typeof contentSourceSchema>;
386
345
 
387
346
  const contentConfigSchema = z
388
347
  .object({
389
- /**
390
- * Extra top-level directories (relative to the project root) served as
391
- * static assets at the site root, alongside `public/`. Lets projects keep
392
- * root-served asset folders in place — e.g. a Mintlify migration keeps
393
- * `images/` where it is instead of relocating it under `public/`.
394
- */
395
- assets: z.array(z.string()).default([]),
396
348
  defaultType: z.string().default("doc"),
397
349
  exclude: z.array(z.string()).default(["**/_*", "**/.*"]),
398
350
  include: z.array(z.string()).default(["**/*.{md,mdx}"]),
@@ -483,10 +435,6 @@ const sidebarItemSchema: z.ZodType<SidebarItemConfig> = z.lazy(() =>
483
435
  ])
484
436
  );
485
437
 
486
- const variablesConfigSchema = z
487
- .record(z.string().regex(/^[A-Za-z0-9-]+$/u), z.string())
488
- .default({});
489
-
490
438
  /** A curated Google Font slug (see `theme/fonts.ts`). */
491
439
  const fontSlug = z.string().refine(isFontSlug, (value) => ({
492
440
  message: `Unknown font "${value}". Supported fonts: ${FONT_SLUGS.join(", ")}.`,
@@ -499,7 +447,6 @@ const themeConfigSchema = z
499
447
  action: z.string().optional(),
500
448
  background: z.string().optional(),
501
449
  backgroundDark: z.string().optional(),
502
- backgroundDecoration: z.enum(["gradient", "grid", "windows"]).optional(),
503
450
  backgroundImage: z.string().optional(),
504
451
  backgroundImageDark: z.string().optional(),
505
452
  fonts: z
@@ -584,7 +531,6 @@ const searchConfigSchema = z
584
531
  .default({}),
585
532
  mixedbread: mixedbreadSearchSchema.optional(),
586
533
  oramaCloud: oramaCloudSearchSchema.optional(),
587
- prompt: z.string().optional(),
588
534
  provider: z.enum(searchProviders).default("orama"),
589
535
  typesense: typesenseSearchSchema.optional(),
590
536
  })
@@ -644,16 +590,8 @@ const aiConfigSchema = z
644
590
  })
645
591
  .strict();
646
592
 
647
- const chromeVariantSchema = z
648
- .object({
649
- banner: bannerConfigSchema.optional(),
650
- path: z.string(),
651
- })
652
- .strict();
653
-
654
593
  const navigationConfigSchema = z
655
594
  .object({
656
- chromeVariants: z.array(chromeVariantSchema).default([]),
657
595
  /** Show a GitHub repo link in the header (requires `github` configured). */
658
596
  repo: z.boolean().default(true),
659
597
  selectors: z.array(navSelectorSchema).default([]),
@@ -824,7 +762,6 @@ const rssConfigSchema = z
824
762
  /** Discoverability features: OG images, feeds, sitemap, structured data. */
825
763
  const seoConfigSchema = z
826
764
  .object({
827
- metatags: z.record(z.string(), z.string()).default({}),
828
765
  og: ogConfigSchema.default({}),
829
766
  /** Generate robots.txt (with a Sitemap reference when available). */
830
767
  robots: z.boolean().default(true),
@@ -1001,17 +938,6 @@ const tocConfigSchema = z
1001
938
  };
1002
939
  });
1003
940
 
1004
- /**
1005
- * Which icon library bare `icon` names resolve against (mirrors Mintlify's
1006
- * `icons.library`). Names can always opt into a specific set with an explicit
1007
- * `prefix:name` (`lucide:rocket`, `fa6-brands:github`) regardless of this.
1008
- */
1009
- const iconsConfigSchema = z
1010
- .object({
1011
- library: z.enum(["lucide", "fontawesome", "tabler"]).default("lucide"),
1012
- })
1013
- .strict();
1014
-
1015
941
  export const blumeConfigSchema = z
1016
942
  .object({
1017
943
  ai: aiConfigSchema.default({}),
@@ -1036,11 +962,9 @@ export const blumeConfigSchema = z
1036
962
  */
1037
963
  examples: z.string().default("examples"),
1038
964
  export: exportConfigSchema.default(false),
1039
- favicon: faviconConfigSchema.optional(),
1040
965
  feedback: z.boolean().default(true),
1041
966
  github: githubConfigSchema.optional(),
1042
967
  i18n: i18nConfigSchema.optional(),
1043
- icons: iconsConfigSchema.default({}),
1044
968
  lastModified: lastModifiedConfigSchema.default(false),
1045
969
  logo: logoConfigSchema.optional(),
1046
970
  markdown: markdownConfigSchema.default({}),
@@ -1053,7 +977,6 @@ export const blumeConfigSchema = z
1053
977
  theme: themeConfigSchema.default({}),
1054
978
  title: z.string().default("Documentation"),
1055
979
  toc: tocConfigSchema,
1056
- variables: variablesConfigSchema,
1057
980
  })
1058
981
  .strict();
1059
982
 
@@ -7,6 +7,11 @@ import { glob } from "tinyglobby";
7
7
  import { BlumeError } from "../diagnostics.ts";
8
8
  import matter from "../frontmatter.ts";
9
9
  import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
10
+ import {
11
+ BLUME_WATCH_IGNORE_DIRS,
12
+ excludeDirSegments,
13
+ ignoringWatchListener,
14
+ } from "./watch.ts";
10
15
 
11
16
  /** Options for the built-in filesystem source. */
12
17
  export interface FilesystemSourceOptions {
@@ -75,13 +80,26 @@ export const filesystemSource = (
75
80
  }
76
81
  };
77
82
 
83
+ // When `content.root` is the project root (a migrated `.`-rooted project),
84
+ // the recursive dev watcher would otherwise see Blume's own `.blume/` output
85
+ // and loop; skip it, VCS/dependency trees, and every excluded dir so the
86
+ // watcher stays in sync with what `load()` globs. See {@link ignoringWatchListener}.
87
+ const watchIgnoreDirs = new Set([
88
+ ...BLUME_WATCH_IGNORE_DIRS,
89
+ ...excludeDirSegments(options.exclude),
90
+ ]);
91
+
78
92
  const watch = (onChange: () => void): (() => void) => {
79
93
  if (!existsSync(contentRoot)) {
80
94
  return () => {
81
95
  // Nothing to dispose when the root doesn't exist yet.
82
96
  };
83
97
  }
84
- const watcher = fsWatch(contentRoot, { recursive: true }, onChange);
98
+ const watcher = fsWatch(
99
+ contentRoot,
100
+ { recursive: true },
101
+ ignoringWatchListener(onChange, watchIgnoreDirs)
102
+ );
85
103
  return () => watcher.close();
86
104
  };
87
105
 
@@ -91,9 +91,23 @@ interface RemoteRef {
91
91
  editUrl?: string;
92
92
  }
93
93
 
94
- const githubHeaders = (): Record<string, string> => {
94
+ // Hosts the GITHUB_TOKEN may be sent to. A configured `url` base can point at
95
+ // any server, and leaking the token there would hand a repo credential to an
96
+ // arbitrary third party.
97
+ const GITHUB_HOSTS = new Set(["api.github.com", "raw.githubusercontent.com"]);
98
+
99
+ const githubHeaders = (url: string): Record<string, string> => {
95
100
  const token = process.env.GITHUB_TOKEN;
96
- return token ? { authorization: `Bearer ${token}` } : {};
101
+ if (!token) {
102
+ return {};
103
+ }
104
+ let host = "";
105
+ try {
106
+ host = new URL(url).hostname;
107
+ } catch {
108
+ return {};
109
+ }
110
+ return GITHUB_HOSTS.has(host) ? { authorization: `Bearer ${token}` } : {};
97
111
  };
98
112
 
99
113
  interface GithubTreeEntry {
@@ -110,7 +124,7 @@ const enumerateGithub = async (
110
124
  const { owner, repo, ref } = github;
111
125
  const base = github.path.replaceAll(/^\/|\/$/gu, "");
112
126
  const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`;
113
- const res = await doFetch(treeUrl, { headers: githubHeaders() });
127
+ const res = await doFetch(treeUrl, { headers: githubHeaders(treeUrl) });
114
128
  if (!res.ok) {
115
129
  throw new Error(`${treeUrl} -> ${res.status}`);
116
130
  }
@@ -172,7 +186,9 @@ export const mdxRemoteSource = (
172
186
  };
173
187
 
174
188
  const fetchEntry = async (item: RemoteRef): Promise<SourceEntry> => {
175
- const res = await doFetch(item.fetchUrl, { headers: githubHeaders() });
189
+ const res = await doFetch(item.fetchUrl, {
190
+ headers: githubHeaders(item.fetchUrl),
191
+ });
176
192
  if (!res.ok) {
177
193
  throw new Error(`${item.fetchUrl} -> ${res.status}`);
178
194
  }
@@ -59,6 +59,11 @@ const mapRoute = (
59
59
  const groups: string[] = [];
60
60
 
61
61
  for (const part of rawParts) {
62
+ // A leading/trailing/double slash yields an empty part; keeping it would
63
+ // produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
64
+ if (part === "") {
65
+ continue;
66
+ }
62
67
  const group = groupLabel(part);
63
68
  if (group !== null) {
64
69
  groups.push(group);
@@ -113,10 +118,11 @@ export const extractHeadings = (body: string): Heading[] => {
113
118
  };
114
119
 
115
120
  const MD_LINK = /\[[^\]]*\]\((?<target>[^)\s]+)(?:\s+"[^"]*")?\)/gu;
121
+ const INLINE_CODE = /`[^`]*`/gu;
116
122
 
117
123
  /**
118
124
  * Extract link targets from a markdown body for later validation, recording the
119
- * 1-based line/column of each target. Skips fenced code blocks.
125
+ * 1-based line/column of each target. Skips fenced code blocks and inline code.
120
126
  */
121
127
  export const extractLinks = (body: string): PageLink[] => {
122
128
  const links: PageLink[] = [];
@@ -132,7 +138,12 @@ export const extractLinks = (body: string): PageLink[] => {
132
138
  if (inFence) {
133
139
  continue;
134
140
  }
135
- for (const match of line.matchAll(MD_LINK)) {
141
+ // Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
142
+ // with same-length padding so recorded columns stay accurate.
143
+ const masked = line.replaceAll(INLINE_CODE, (span) =>
144
+ " ".repeat(span.length)
145
+ );
146
+ for (const match of masked.matchAll(MD_LINK)) {
136
147
  const target = match.groups?.target;
137
148
  if (target === undefined || match.index === undefined) {
138
149
  continue;
@@ -153,7 +164,6 @@ export const extractLinks = (body: string): PageLink[] => {
153
164
  return links;
154
165
  };
155
166
 
156
- const INLINE_CODE = /`[^`]*`/gu;
157
167
  // Double-quoted strings hold JSX attribute values and JSON in `{...}` props; a
158
168
  // `<Tag>` written inside prose there (e.g. an "Astro <Font> integration" note)
159
169
  // isn't a real usage. Single quotes are left alone so prose apostrophes don't
@@ -207,8 +217,14 @@ const deriveTitle = (
207
217
  return titleCase(stripNumericPrefix(base.replace(extname(base), "")));
208
218
  };
209
219
 
210
- const withPrefix = (prefix: string | undefined, path: string): string =>
211
- prefix ? `${prefix}/${path}` : path;
220
+ /** Strip habitual leading/trailing slashes (`/getting-started`, `guides/`). */
221
+ const trimSlashes = (value: string): string =>
222
+ value.replaceAll(/^\/+|\/+$/gu, "");
223
+
224
+ const withPrefix = (prefix: string | undefined, path: string): string => {
225
+ const clean = prefix ? trimSlashes(prefix) : "";
226
+ return clean ? `${clean}/${path}` : path;
227
+ };
212
228
 
213
229
  /**
214
230
  * Normalize one source entry into per-locale `PageRecord`s. This is the single
@@ -254,9 +270,15 @@ export const normalizeEntry = (
254
270
  : { locales: [""], navPath: entry.ref };
255
271
 
256
272
  const navPath = withPrefix(ctx.source.prefix, rawNavPath);
273
+ // Frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
274
+ // SPI's "logical route input; defaults to ref if omitted"), then the ref.
275
+ // The extension is re-appended so mapRoute's extname strip can't eat a
276
+ // dotted slug segment (`v1.2`). A slug that trims to nothing falls back.
277
+ const slugInput = meta.slug ?? entry.slug;
278
+ const slug = slugInput ? trimSlashes(slugInput) : "";
257
279
  const routeInput = withPrefix(
258
280
  ctx.source.prefix,
259
- meta.slug ? `${meta.slug}${ext}` : rawNavPath
281
+ slug ? `${slug}${ext}` : rawNavPath
260
282
  );
261
283
 
262
284
  const { segments, groups, route: logicalRoute } = mapRoute(routeInput);
@@ -7,7 +7,6 @@ import type { ProjectContext } from "../types.ts";
7
7
  import { filesystemSource } from "./filesystem.ts";
8
8
  import { githubReleasesSource } from "./github-releases.ts";
9
9
  import { mdxRemoteSource } from "./mdx-remote.ts";
10
- import { mintlifySource } from "./mintlify.ts";
11
10
  import { notionSource } from "./notion.ts";
12
11
  import { sanitySource } from "./sanity.ts";
13
12
  import type { ContentSource, SourceContext } from "./types.ts";
@@ -64,18 +63,6 @@ const buildSource = (
64
63
  root: def.root,
65
64
  });
66
65
  }
67
- if (def.type === "mintlify") {
68
- return mintlifySource({
69
- configFile: def.configFile,
70
- exclude: def.exclude,
71
- include: def.include,
72
- name,
73
- prefix: def.prefix,
74
- projectRoot: context.root,
75
- root: def.root,
76
- variables: def.variables,
77
- });
78
- }
79
66
  if (def.type === "custom") {
80
67
  // A user-provided instance manages its own context/caching; we only ensure
81
68
  // its name is unique across the project for id namespacing.
@@ -0,0 +1,45 @@
1
+ import type { WatchListener } from "node:fs";
2
+
3
+ /**
4
+ * Directory segments a recursive dev watcher must never react to. When a
5
+ * source's content root is the project root — a `.`-rooted layout, or an
6
+ * all-staged project (openapi/notion/github-releases/…) with no filesystem
7
+ * source — a naive recursive `fs.watch` also sees Blume's own `.blume/`
8
+ * output, which the dev server rewrites on every render (e.g.
9
+ * `.blume/.astro/data-store.json`). Left unfiltered, each such write re-triggers
10
+ * a rescan + runtime regeneration whose writes land back under `.blume/` and
11
+ * fire the watcher again: a self-sustaining loop that stalls page renders and
12
+ * floods the console (and, mid-render, corrupts Astro's dev module graph so
13
+ * `astro:server-app.js` fails to load). `.git`/`node_modules` are here for the
14
+ * same reason — churn that is never page content. `fs.watch` has no ignore
15
+ * option, so we filter by the changed path in the callback.
16
+ */
17
+ export const BLUME_WATCH_IGNORE_DIRS = [".blume", ".git", "node_modules"];
18
+
19
+ /** Extract single-segment ignore dirs (`foo`) from `foo/**`-style excludes. */
20
+ export const excludeDirSegments = (patterns: readonly string[]): string[] =>
21
+ patterns
22
+ .map((pattern) => /^(?<dir>[^*/]+)\/\*\*$/u.exec(pattern)?.groups?.dir)
23
+ .filter((dir): dir is string => dir !== undefined);
24
+
25
+ /**
26
+ * Build a recursive-watch listener that fires `onChange` for content changes but
27
+ * ignores events whose path crosses an ignored directory segment. A missing
28
+ * `filename` — rare; the platform couldn't name the changed path — falls through
29
+ * to `onChange` rather than silently dropping a real edit. Exported for testing.
30
+ */
31
+ export const ignoringWatchListener = (
32
+ onChange: () => void,
33
+ ignoreDirs: Iterable<string> = BLUME_WATCH_IGNORE_DIRS
34
+ ): WatchListener<string> => {
35
+ const ignore = new Set(ignoreDirs);
36
+ return (_event, filename) => {
37
+ if (
38
+ typeof filename === "string" &&
39
+ filename.split(/[/\\]/u).some((segment) => ignore.has(segment))
40
+ ) {
41
+ return;
42
+ }
43
+ onChange();
44
+ };
45
+ };