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
@@ -1,117 +0,0 @@
1
- import { createReadStream, existsSync, statSync } from "node:fs";
2
- import type { Stats } from "node:fs";
3
- import { cp } from "node:fs/promises";
4
- import type { IncomingMessage, ServerResponse } from "node:http";
5
-
6
- import { extname, join, relative, resolve, sep } from "pathe";
7
-
8
- import type { AssetMount } from "../core/assets.ts";
9
-
10
- /** Content types for the asset extensions a docs project commonly serves. */
11
- const MIME_TYPES: Record<string, string> = {
12
- ".avif": "image/avif",
13
- ".css": "text/css; charset=utf-8",
14
- ".gif": "image/gif",
15
- ".ico": "image/x-icon",
16
- ".jpeg": "image/jpeg",
17
- ".jpg": "image/jpeg",
18
- ".js": "text/javascript; charset=utf-8",
19
- ".json": "application/json; charset=utf-8",
20
- ".mp4": "video/mp4",
21
- ".otf": "font/otf",
22
- ".pdf": "application/pdf",
23
- ".png": "image/png",
24
- ".svg": "image/svg+xml",
25
- ".ttf": "font/ttf",
26
- ".txt": "text/plain; charset=utf-8",
27
- ".webm": "video/webm",
28
- ".webp": "image/webp",
29
- ".woff": "font/woff",
30
- ".woff2": "font/woff2",
31
- };
32
-
33
- const mimeType = (file: string): string =>
34
- MIME_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
35
-
36
- /** Whether `child` is `parent` itself or a path nested under it. */
37
- const isContained = (parent: string, child: string): boolean => {
38
- const rel = relative(parent, child);
39
- return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
40
- };
41
-
42
- /** Resolve a request URL to an on-disk file within one of the mounts, if any. */
43
- const resolveRequest = (url: string, mounts: AssetMount[]): string | null => {
44
- const pathname = decodeURIComponent(
45
- (url.split("?")[0] ?? "").split("#")[0] ?? ""
46
- );
47
- for (const mount of mounts) {
48
- if (pathname !== mount.url && !pathname.startsWith(`${mount.url}/`)) {
49
- continue;
50
- }
51
- // `.` + rel keeps the join relative so an absolute-looking suffix can't
52
- // escape the mount; the containment check rejects `..` traversal outright.
53
- const file = resolve(mount.dir, `.${pathname.slice(mount.url.length)}`);
54
- if (isContained(mount.dir, file)) {
55
- return file;
56
- }
57
- }
58
- return null;
59
- };
60
-
61
- /** `statSync` that returns null instead of throwing on a missing file. */
62
- const statFile = (file: string): Stats | null => {
63
- try {
64
- return statSync(file);
65
- } catch {
66
- return null;
67
- }
68
- };
69
-
70
- /**
71
- * A dev-server middleware that serves `content.assets` mounts (top-level dirs
72
- * kept in place, e.g. a migrated `images/`) at their site URL. Astro only serves
73
- * `publicDir` in dev, so without this those references would 404. Non-matching
74
- * requests, and any path that isn't a real file, fall through to Astro. Stat is
75
- * synchronous — this is dev-only middleware and mirrors how sirv serves statics.
76
- */
77
- export const serveAssetMounts =
78
- (mounts: AssetMount[]) =>
79
- (req: IncomingMessage, res: ServerResponse, next: () => void): void => {
80
- if ((req.method !== "GET" && req.method !== "HEAD") || !req.url) {
81
- next();
82
- return;
83
- }
84
- const file = resolveRequest(req.url, mounts);
85
- const stats = file ? statFile(file) : null;
86
- if (!(file && stats?.isFile())) {
87
- next();
88
- return;
89
- }
90
- res.setHeader("Content-Type", mimeType(file));
91
- res.setHeader("Content-Length", String(stats.size));
92
- if (req.method === "HEAD") {
93
- res.end();
94
- return;
95
- }
96
- createReadStream(file).pipe(res);
97
- };
98
-
99
- /**
100
- * Copy every asset mount into the build output, mirroring what Astro does with
101
- * `publicDir`. Runs on `astro:build:done` so in-place asset dirs ship in the
102
- * final `dist/`. A missing source dir is skipped (it may be referenced but
103
- * absent); `cp` handles both directories and single files.
104
- */
105
- export const copyAssetMounts = async (
106
- mounts: AssetMount[],
107
- outDir: string
108
- ): Promise<void> => {
109
- for (const mount of mounts) {
110
- if (!existsSync(mount.dir)) {
111
- continue;
112
- }
113
- const dest = join(outDir, mount.url.replace(/^\/+/u, ""));
114
- // oxlint-disable-next-line no-await-in-loop -- sequential fs copies
115
- await cp(mount.dir, dest, { recursive: true });
116
- }
117
- };
@@ -1,39 +0,0 @@
1
- import { defineCommand } from "citty";
2
-
3
- import { migrators } from "../../migrate/migrate.ts";
4
- import { logger } from "../log.ts";
5
-
6
- const makeMigrateCommand = (source: keyof typeof migrators) =>
7
- defineCommand({
8
- meta: {
9
- description: `Migrate a ${source} project to Blume.`,
10
- name: source,
11
- },
12
- async run() {
13
- const root = process.cwd();
14
- logger.start(`Migrating ${source} project`);
15
- const result = await migrators[source]?.(root);
16
- if (!result) {
17
- logger.error(`No migrator for ${source}.`);
18
- process.exit(1);
19
- }
20
- logger.success(`Migrated ${result.moved} content file(s).`);
21
- for (const warning of result.warnings) {
22
- logger.warn(warning);
23
- }
24
- logger.box("Review blume.config.ts and run `blume dev`.");
25
- },
26
- });
27
-
28
- export const migrateCommand = defineCommand({
29
- meta: {
30
- description: "Migrate from another docs tool to Blume.",
31
- name: "migrate",
32
- },
33
- subCommands: {
34
- fumadocs: makeMigrateCommand("fumadocs"),
35
- mintlify: makeMigrateCommand("mintlify"),
36
- nextra: makeMigrateCommand("nextra"),
37
- starlight: makeMigrateCommand("starlight"),
38
- },
39
- });
@@ -1,75 +0,0 @@
1
- ---
2
- // Shared field row behind the Mintlify-compat <ParamField>/<ResponseField>/
3
- // <RequestField> components: a labeled name + type + badges, with the field's
4
- // description rendered from the slot (which may hold rich MDX, including nested
5
- // <Expandable> or further fields). Styled to match the native OpenAPI reference
6
- // rows (src/components/openapi/), so a migrated site reads consistently.
7
- interface Props {
8
- default?: unknown;
9
- deprecated?: unknown;
10
- location?: string;
11
- name?: string;
12
- required?: unknown;
13
- type?: string;
14
- }
15
-
16
- const {
17
- default: defaultValue,
18
- deprecated,
19
- location,
20
- name,
21
- required,
22
- type,
23
- } = Astro.props;
24
-
25
- // Booleans arrive as MDX shorthand (`required`) or strings (`required="true"`).
26
- const isTrue = (value: unknown): boolean => value === true || value === "true";
27
- const hasDefault =
28
- defaultValue !== undefined && defaultValue !== null && defaultValue !== "";
29
- const hasDescription = Astro.slots.has("default");
30
- ---
31
-
32
- <div class="not-prose my-2 rounded-blume border border-border px-4 py-3">
33
- <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
34
- {name && <code class="font-mono text-foreground text-sm">{name}</code>}
35
- {
36
- location && (
37
- <span class="rounded bg-muted px-1.5 py-0.5 font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide">
38
- {location}
39
- </span>
40
- )
41
- }
42
- {type && <span class="text-muted-foreground text-xs">{type}</span>}
43
- {
44
- isTrue(required) && (
45
- <span class="font-medium text-[0.625rem] text-red-600 uppercase tracking-wide dark:text-red-400">
46
- required
47
- </span>
48
- )
49
- }
50
- {
51
- isTrue(deprecated) && (
52
- <span class="font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide line-through">
53
- deprecated
54
- </span>
55
- )
56
- }
57
- </div>
58
- {
59
- hasDefault && (
60
- <div class="mt-1 text-muted-foreground text-xs">
61
- Default:{" "}
62
- <code class="rounded bg-muted px-1 py-0.5 text-foreground">
63
- {String(defaultValue)}
64
- </code>
65
- </div>
66
- )
67
- }
68
- {
69
- hasDescription && (
70
- <div class="mt-1.5 text-muted-foreground text-sm [&>:first-child]:mt-0 [&>:last-child]:mb-0">
71
- <slot />
72
- </div>
73
- )
74
- }
75
- </div>
@@ -1,39 +0,0 @@
1
- ---
2
- // Mintlify-compat <ParamField>. Mintlify encodes the parameter's location in the
3
- // attribute name (`<ParamField path="id" />`, `query`, `header`, `body`); the
4
- // attribute's value is the field name. Falls back to a plain `name` prop.
5
- import ApiField from "./ApiField.astro";
6
-
7
- const {
8
- body,
9
- default: defaultValue,
10
- deprecated,
11
- header,
12
- name,
13
- path,
14
- query,
15
- required,
16
- type,
17
- } = Astro.props;
18
-
19
- const locations = [
20
- { key: "path", value: path },
21
- { key: "query", value: query },
22
- { key: "header", value: header },
23
- { key: "body", value: body },
24
- ] as const;
25
- const located = locations.find((entry) => typeof entry.value === "string");
26
- const fieldName = located ? located.value : name;
27
- const location = located?.key;
28
- ---
29
-
30
- <ApiField
31
- default={defaultValue}
32
- deprecated={deprecated}
33
- location={location}
34
- name={fieldName}
35
- required={required}
36
- type={type}
37
- >
38
- <slot />
39
- </ApiField>
@@ -1,23 +0,0 @@
1
- ---
2
- // Mintlify-compat <RequestField>: same shape as <ResponseField> — a named field
3
- // (`name`, `type`, `required`, `default`, `deprecated`) with a slotted body.
4
- import ApiField from "./ApiField.astro";
5
-
6
- const {
7
- default: defaultValue,
8
- deprecated,
9
- name,
10
- required,
11
- type,
12
- } = Astro.props;
13
- ---
14
-
15
- <ApiField
16
- default={defaultValue}
17
- deprecated={deprecated}
18
- name={name}
19
- required={required}
20
- type={type}
21
- >
22
- <slot />
23
- </ApiField>
@@ -1,23 +0,0 @@
1
- ---
2
- // Mintlify-compat <ResponseField>: a named field (`name`, `type`, `required`,
3
- // `default`, `deprecated`) with its description in the slot.
4
- import ApiField from "./ApiField.astro";
5
-
6
- const {
7
- default: defaultValue,
8
- deprecated,
9
- name,
10
- required,
11
- type,
12
- } = Astro.props;
13
- ---
14
-
15
- <ApiField
16
- default={defaultValue}
17
- deprecated={deprecated}
18
- name={name}
19
- required={required}
20
- type={type}
21
- >
22
- <slot />
23
- </ApiField>
@@ -1,9 +0,0 @@
1
- ---
2
- import Callout from "./Callout.astro";
3
-
4
- const { title } = Astro.props;
5
- ---
6
-
7
- <Callout title={title} type="warning">
8
- <slot />
9
- </Callout>
@@ -1,31 +0,0 @@
1
- import { join } from "pathe";
2
-
3
- /** A static directory served at a URL prefix, in addition to `public/`. */
4
- export interface AssetMount {
5
- /** Absolute filesystem path to the source directory (or file). */
6
- dir: string;
7
- /** URL path prefix the source is served at, e.g. `/images`. */
8
- url: string;
9
- }
10
-
11
- /**
12
- * Resolve `content.assets` entries (top-level dirs served at the site root,
13
- * alongside `public/`) to `{ dir, url }` mounts. Shared by the generated Astro
14
- * runtime (dev middleware + build copy) and by link validation, so all three
15
- * agree on where a `/images/foo.png` reference resolves on disk.
16
- *
17
- * Each entry is normalized to a leading-slash URL and joined to the project
18
- * root; leading `./` or `/` and any `..` segments are stripped so a mount can't
19
- * escape the root or collide with the site's own routing prefix.
20
- */
21
- export const resolveAssetMounts = (
22
- root: string,
23
- assets: string[]
24
- ): AssetMount[] =>
25
- assets.map((entry) => {
26
- const rel = entry
27
- .replace(/^[./]+/u, "")
28
- .replaceAll(/\.\.\/?/gu, "")
29
- .replace(/\/+$/u, "");
30
- return { dir: join(root, rel), url: `/${rel}` };
31
- });
@@ -1,102 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
-
4
- import { join } from "pathe";
5
-
6
- import { assetSegments } from "../migrate/mintlify/assets.ts";
7
- import { loadMintlifyConfig } from "../migrate/mintlify/config.ts";
8
- import { mintlifyI18n } from "../migrate/mintlify/i18n.ts";
9
- import type { BlumeConfig } from "./schema.ts";
10
-
11
- /** A detected docs-tool config that Blume can serve without a migration. */
12
- export interface BridgeDetection {
13
- /** Which foreign docs tool was detected. */
14
- tool: "mintlify";
15
- /** Absolute path of the detected config file (`docs.json`/`mint.json`). */
16
- configFile: string;
17
- /** A Blume config synthesized from the foreign config, ready to validate. */
18
- raw: BlumeConfig;
19
- }
20
-
21
- const MINTLIFY_CONFIG_FILES = ["docs.json", "mint.json"];
22
-
23
- /**
24
- * Detect a Mintlify project at `root` and synthesize an equivalent Blume config.
25
- *
26
- * "Bridge mode" lets a team swap `mintlify dev` for `blume dev` with no file
27
- * changes: `docs.json` is translated to Blume config (`loadMintlifyConfig`) and
28
- * its content block is rewired to a single `mintlify` content source, which
29
- * transforms each MDX page to Blume idiom at scan time. The original
30
- * `content.root`/`exclude` and `variables` move onto the source (Blume has no
31
- * runtime variable substitution, so globals are inlined into content there).
32
- *
33
- * Returns `null` when no Mintlify config is present. Only called when no
34
- * `blume.config.*` exists, so an explicit Blume config always takes precedence.
35
- */
36
- export const detectMintlifyBridge = async (
37
- root: string
38
- ): Promise<BridgeDetection | null> => {
39
- const configFile = MINTLIFY_CONFIG_FILES.map((name) => join(root, name)).find(
40
- (candidate) => existsSync(candidate)
41
- );
42
- if (!configFile) {
43
- return null;
44
- }
45
-
46
- const config = await loadMintlifyConfig(root, configFile);
47
-
48
- // i18n is derived from the raw spec (language dirs), mirroring the migrator.
49
- const spec = JSON.parse(await readFile(configFile, "utf-8")) as Record<
50
- string,
51
- unknown
52
- >;
53
- const i18n = mintlifyI18n(spec);
54
- if (i18n) {
55
- config.i18n = i18n;
56
- // Language switching is handled by Blume i18n, not a nav selector.
57
- if (config.navigation?.selectors) {
58
- config.navigation.selectors = config.navigation.selectors.filter(
59
- (selector) => selector.kind !== "language"
60
- );
61
- }
62
- }
63
-
64
- const variables = (config.variables as Record<string, string>) ?? {};
65
- const root_ = config.content?.root ?? ".";
66
- const exclude = config.content?.exclude ?? [];
67
-
68
- // Mintlify serves assets from the project root; the bridge never moves files,
69
- // so referenced root-level asset folders (e.g. `images/`) are served in place
70
- // via `content.assets` instead. This is the read-only twin of the migrator's
71
- // relocation — same referenced segments, just no `public/` move.
72
- const assets = assetSegments(config).filter(
73
- (segment) => segment !== "public" && existsSync(join(root, segment))
74
- );
75
-
76
- return {
77
- configFile,
78
- raw: {
79
- ...config,
80
- content: {
81
- assets,
82
- // Mirror the excludes onto `content.exclude` too: the generated Astro
83
- // `docs` collection globs `content.root` (here the project root) and
84
- // must skip node_modules/snippets just like the source does.
85
- exclude,
86
- root: root_,
87
- sources: [
88
- {
89
- configFile,
90
- exclude,
91
- root: root_,
92
- type: "mintlify",
93
- variables,
94
- },
95
- ],
96
- },
97
- // Globals are inlined into content by the source; drop the top-level copy.
98
- variables: undefined,
99
- },
100
- tool: "mintlify",
101
- };
102
- };
@@ -1,204 +0,0 @@
1
- import { existsSync, watch as fsWatch } from "node:fs";
2
- import type { WatchListener } from "node:fs";
3
- import { readFile } from "node:fs/promises";
4
-
5
- import { isAbsolute, join, relative, resolve } from "pathe";
6
- import { glob } from "tinyglobby";
7
-
8
- import { transformMintlifyContent } from "../../migrate/mintlify/transform.ts";
9
- import { BlumeError } from "../diagnostics.ts";
10
- import matter from "../frontmatter.ts";
11
- import type { Diagnostic } from "../types.ts";
12
- import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
13
-
14
- /** Options for the Mintlify bridge content source. */
15
- export interface MintlifySourceOptions {
16
- /** Stable source name; namespaces ids and diagnostics. */
17
- name: string;
18
- /** Optional route prefix. */
19
- prefix?: string;
20
- /** Content root, absolute or relative to `projectRoot` (Mintlify: `.`). */
21
- root: string;
22
- include: string[];
23
- exclude: string[];
24
- /** `docs.json` variables, inlined into content (`{{name}}`) at scan time. */
25
- variables: Record<string, string>;
26
- /** Absolute path of the `docs.json`/`mint.json`, watched for changes in dev. */
27
- configFile?: string;
28
- /** Absolute project root, used to resolve a relative `root`. */
29
- projectRoot: string;
30
- }
31
-
32
- /**
33
- * Folders Mintlify projects keep alongside content that are never pages:
34
- * snippets are inlined as includes, and build/tooling dirs are noise. Merged
35
- * with the user's `exclude` so bridge mode behaves like the one-shot migrator.
36
- */
37
- const MINTLIFY_SOURCE_IGNORES = [
38
- "node_modules/**",
39
- ".blume/**",
40
- "dist/**",
41
- "build/**",
42
- "public/**",
43
- "snippets/**",
44
- ];
45
-
46
- /**
47
- * Directory names the recursive dev watcher must ignore. In bridge mode the
48
- * content root is the project root, so a naive recursive `fs.watch` also sees
49
- * Blume's own `.blume/` output — which the dev server rewrites on every request
50
- * (`.blume/.astro/data-store.json`). Left unfiltered, each such write re-triggers
51
- * a full rescan + runtime regeneration, whose writes land back under `.blume/`
52
- * and fire the watcher again: a self-sustaining storm that stalls page renders
53
- * and floods the console. `fs.watch` has no ignore option, so we filter by the
54
- * changed path in the callback. Derived from {@link MINTLIFY_SOURCE_IGNORES}
55
- * (dir prefixes) plus VCS metadata.
56
- */
57
- const WATCH_IGNORE_DIRS = new Set([
58
- ...MINTLIFY_SOURCE_IGNORES.map((pattern) => pattern.replace(/\/\*\*$/u, "")),
59
- ".git",
60
- ]);
61
-
62
- /**
63
- * Build the recursive-watch listener: fire `onChange` for content changes but
64
- * ignore events whose path crosses a {@link WATCH_IGNORE_DIRS} segment (Blume's
65
- * own `.blume/` output, `node_modules`, VCS metadata, …). A missing `filename`
66
- * — rare; the platform couldn't name the changed path — falls through to
67
- * regenerate rather than silently dropping a real edit. Exported for testing.
68
- */
69
- export const mintlifyWatchListener =
70
- (onChange: () => void): WatchListener<string> =>
71
- (_event, filename) => {
72
- if (
73
- typeof filename === "string" &&
74
- filename.split(/[/\\]/u).some((segment) => WATCH_IGNORE_DIRS.has(segment))
75
- ) {
76
- return;
77
- }
78
- onChange();
79
- };
80
-
81
- /**
82
- * The Mintlify bridge content source. Reads an unconverted Mintlify project in
83
- * place and transforms each page to Blume MDX at scan time (callouts → `:::`
84
- * directives, snippet/variable inlining, etc.) via `transformMintlifyContent`.
85
- * Staged: the transformed bodies are materialized under `.blume/content` and
86
- * rendered through Astro's `staged` collection, so the rewrites actually reach
87
- * the output. Components Blume already ships (Card, Tabs, Steps, …) render as-is.
88
- */
89
- export const mintlifySource = (
90
- options: MintlifySourceOptions
91
- ): ContentSource & { readonly contentRoot: string } => {
92
- const contentRoot = isAbsolute(options.root)
93
- ? options.root
94
- : join(resolve(options.projectRoot), options.root);
95
- const ignore = [...new Set([...options.exclude, ...MINTLIFY_SOURCE_IGNORES])];
96
-
97
- const transform = (
98
- raw: string,
99
- file: string
100
- ): ReturnType<typeof transformMintlifyContent> =>
101
- transformMintlifyContent(raw, {
102
- filePath: file,
103
- root: resolve(options.projectRoot),
104
- variables: options.variables,
105
- });
106
-
107
- const load = async (): Promise<SourceLoadResult> => {
108
- const files = await glob(options.include, {
109
- absolute: true,
110
- cwd: contentRoot,
111
- ignore,
112
- onlyFiles: true,
113
- });
114
- files.sort();
115
-
116
- const unsupported = new Set<string>();
117
- const entries = await Promise.all(
118
- files.map(async (file): Promise<SourceEntry> => {
119
- const result = await transform(await readFile(file, "utf-8"), file);
120
- for (const name of result.unsupported) {
121
- unsupported.add(name);
122
- }
123
- const parsed = matter(result.content);
124
- // Force MDX: Mintlify pages are MDX-authored and the rewrites emit `:::`
125
- // directives + JSX, neither of which the plain `.md` processor expands.
126
- return {
127
- body: { format: "mdx", text: parsed.content },
128
- data: parsed.data,
129
- raw: result.content,
130
- ref: relative(contentRoot, file),
131
- sourcePath: file,
132
- };
133
- })
134
- );
135
-
136
- const diagnostics: Diagnostic[] =
137
- unsupported.size > 0
138
- ? [
139
- {
140
- code: "BLUME_MINTLIFY_UNSUPPORTED",
141
- message: `Mintlify components without a Blume equivalent were left as-is: ${[...unsupported].toSorted().join(", ")}. Replace them by hand or provide a matching component.`,
142
- severity: "warning",
143
- },
144
- ]
145
- : [];
146
-
147
- return { diagnostics, entries };
148
- };
149
-
150
- const validate = (): void => {
151
- if (!existsSync(contentRoot)) {
152
- throw new BlumeError({
153
- code: "BLUME_CONTENT_ROOT_MISSING",
154
- file: contentRoot,
155
- message: `Content root not found: ${options.root}`,
156
- severity: "error",
157
- suggestion: `Run "blume dev" from the directory that contains docs.json.`,
158
- });
159
- }
160
- };
161
-
162
- const watch = (onChange: () => void): (() => void) => {
163
- const disposers: (() => void)[] = [];
164
- if (existsSync(contentRoot)) {
165
- // Recursively watch the content root, but skip Blume's own output and
166
- // other non-content trees so the dev server's `.blume/` writes don't feed
167
- // a regeneration loop (`fs.watch` has no ignore option, so filter here).
168
- const watcher = fsWatch(
169
- contentRoot,
170
- { recursive: true },
171
- mintlifyWatchListener(onChange)
172
- );
173
- disposers.push(() => watcher.close());
174
- }
175
- // Watch docs.json directly: it lives at the content root but a non-recursive
176
- // single-file watch fires reliably on edits that recursive dir-watch can miss.
177
- if (options.configFile && existsSync(options.configFile)) {
178
- const watcher = fsWatch(options.configFile, onChange);
179
- disposers.push(() => watcher.close());
180
- }
181
- return () => {
182
- for (const dispose of disposers) {
183
- dispose();
184
- }
185
- };
186
- };
187
-
188
- const read = async (ref: string): Promise<string> => {
189
- const file = join(contentRoot, ref);
190
- const result = await transform(await readFile(file, "utf-8"), file);
191
- return result.content;
192
- };
193
-
194
- return {
195
- contentRoot,
196
- load,
197
- name: options.name,
198
- prefix: options.prefix,
199
- read,
200
- staged: true,
201
- validate,
202
- watch,
203
- };
204
- };