blume 0.3.0 → 0.5.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 (128) hide show
  1. package/dist/cli/index.js +1631 -940
  2. package/dist/cli/index.js.map +62 -50
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +442 -292
  6. package/dist/types/core/types.d.ts +7 -0
  7. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  8. package/docs/01-quickstart.mdx +5 -16
  9. package/docs/02-deployment.mdx +21 -54
  10. package/docs/advanced/api-reference.mdx +34 -51
  11. package/docs/advanced/blog.mdx +9 -25
  12. package/docs/advanced/bridge.mdx +74 -0
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +21 -78
  15. package/docs/advanced/meta.ts +8 -1
  16. package/docs/advanced/migrate.mdx +119 -0
  17. package/docs/configuration/ai.mdx +42 -103
  18. package/docs/configuration/analytics.mdx +20 -38
  19. package/docs/configuration/customization.mdx +40 -73
  20. package/docs/configuration/export.mdx +9 -34
  21. package/docs/configuration/index.mdx +67 -87
  22. package/docs/configuration/search.mdx +17 -54
  23. package/docs/configuration/seo.mdx +17 -48
  24. package/docs/configuration/theming.mdx +20 -42
  25. package/docs/content/components.mdx +95 -101
  26. package/docs/content/i18n.mdx +21 -72
  27. package/docs/content/index.mdx +18 -48
  28. package/docs/content/islands.mdx +25 -52
  29. package/docs/content/meta.mdx +23 -50
  30. package/docs/content/navigation.mdx +23 -62
  31. package/docs/content/sources.mdx +20 -83
  32. package/docs/content/syntax.mdx +37 -105
  33. package/docs/index.mdx +12 -41
  34. package/docs/reference/cli.mdx +47 -30
  35. package/docs/reference/frontmatter.mdx +7 -5
  36. package/package.json +11 -1
  37. package/src/astro/generate.ts +18 -8
  38. package/src/astro/integration.ts +26 -3
  39. package/src/astro/islands.ts +6 -2
  40. package/src/astro/markdown-negotiation.ts +17 -3
  41. package/src/astro/pages.ts +6 -1
  42. package/src/astro/static-assets.ts +117 -0
  43. package/src/astro/templates.ts +76 -30
  44. package/src/cli/args.ts +23 -0
  45. package/src/cli/commands/build.ts +129 -62
  46. package/src/cli/commands/check.ts +20 -0
  47. package/src/cli/commands/dev.ts +11 -2
  48. package/src/cli/commands/doctor.ts +10 -1
  49. package/src/cli/commands/eject.ts +3 -1
  50. package/src/cli/commands/init.ts +21 -1
  51. package/src/cli/commands/preview.ts +2 -1
  52. package/src/cli/commands/validate.ts +12 -1
  53. package/src/cli/dev-lock.ts +92 -0
  54. package/src/cli/log.ts +11 -0
  55. package/src/cli/prepare.ts +3 -0
  56. package/src/components/BlumePage.astro +8 -0
  57. package/src/components/Icon.astro +13 -10
  58. package/src/components/content/ApiField.astro +75 -0
  59. package/src/components/content/ParamField.astro +39 -0
  60. package/src/components/content/RequestField.astro +23 -0
  61. package/src/components/content/ResponseField.astro +23 -0
  62. package/src/components/content/Step.astro +1 -1
  63. package/src/components/content/YouTube.astro +35 -0
  64. package/src/components/content/youtube.ts +46 -0
  65. package/src/components/islands/ask-ai.tsx +14 -14
  66. package/src/components/layout/Breadcrumbs.astro +7 -2
  67. package/src/components/layout/NavTree.astro +24 -8
  68. package/src/components/layout/RootLayout.astro +56 -34
  69. package/src/components/layout/Search.astro +1 -1
  70. package/src/components/openapi/ApiOverview.astro +84 -0
  71. package/src/components/openapi/MethodBadge.astro +28 -0
  72. package/src/components/openapi/Operation.astro +140 -0
  73. package/src/components/openapi/ParametersTable.astro +97 -0
  74. package/src/components/openapi/RequestBody.astro +58 -0
  75. package/src/components/openapi/RequestPanel.astro +169 -0
  76. package/src/components/openapi/Responses.astro +91 -0
  77. package/src/components/openapi/SchemaProperty.astro +118 -0
  78. package/src/components/openapi/SchemaTable.astro +86 -0
  79. package/src/components/openapi/helpers.ts +238 -0
  80. package/src/components/openapi/panel.ts +59 -0
  81. package/src/components/openapi/snippets.ts +201 -0
  82. package/src/components/props.ts +3 -0
  83. package/src/core/assets.ts +31 -0
  84. package/src/core/bridge.ts +10 -0
  85. package/src/core/builtin-tags.ts +6 -0
  86. package/src/core/data.ts +2 -0
  87. package/src/core/diagnostics.ts +6 -1
  88. package/src/core/gitignore.ts +30 -0
  89. package/src/core/links.ts +60 -19
  90. package/src/core/project-graph.ts +5 -1
  91. package/src/core/project.ts +25 -3
  92. package/src/core/schema.ts +54 -6
  93. package/src/core/sources/mdx-remote.ts +54 -8
  94. package/src/core/sources/mintlify.ts +1 -1
  95. package/src/core/sources/normalize.ts +6 -1
  96. package/src/core/sources/notion.ts +49 -5
  97. package/src/core/sources/resolve.ts +28 -6
  98. package/src/core/sources/sanity.ts +5 -1
  99. package/src/core/types.ts +7 -0
  100. package/src/deploy/rss.ts +1 -8
  101. package/src/deploy/sitemap.ts +20 -1
  102. package/src/deploy/xml.ts +8 -0
  103. package/src/markdown/directives.ts +15 -7
  104. package/src/markdown/package-commands.ts +26 -4
  105. package/src/migrate/fumadocs/content.ts +14 -1
  106. package/src/migrate/fumadocs/groups.ts +7 -0
  107. package/src/migrate/fumadocs/index.ts +5 -2
  108. package/src/migrate/mintlify/assets.ts +46 -0
  109. package/src/migrate/mintlify/config.ts +153 -1
  110. package/src/migrate/mintlify/content.ts +8 -2
  111. package/src/migrate/mintlify/index.ts +111 -46
  112. package/src/migrate/shared.ts +12 -27
  113. package/src/og/card.ts +14 -2
  114. package/src/openapi/model.ts +174 -0
  115. package/src/openapi/parse.ts +48 -0
  116. package/src/openapi/references.ts +164 -0
  117. package/src/openapi/render-mdx.ts +76 -0
  118. package/src/openapi/scalar.ts +15 -103
  119. package/src/openapi/source.ts +140 -0
  120. package/src/registry/eject.ts +28 -5
  121. package/src/registry/registry.ts +6 -0
  122. package/src/registry/rewrite-imports.ts +31 -19
  123. package/src/search/documents.ts +23 -5
  124. package/src/search/sync/algolia.ts +5 -1
  125. package/src/search/sync/typesense.ts +24 -16
  126. package/src/theme/chrome-icons.ts +22 -0
  127. package/src/theme/icons.ts +151 -161
  128. package/src/theme/palette.ts +26 -7
@@ -9,19 +9,19 @@ blume <command> [options]
9
9
 
10
10
  ## Commands
11
11
 
12
- | Command | Description |
13
- | ---------------------- | ------------------------------------------------------ |
14
- | `blume init` | Scaffold a minimal project. |
15
- | `blume dev` | Start the dev server with hot reload. |
16
- | `blume build` | Build the static (or server) site. |
17
- | `blume preview` | Preview the last build. |
18
- | `blume add <item>` | Install a source component from the registry. |
19
- | `blume migrate <tool>` | Migrate from Mintlify, Starlight, Nextra, or Fumadocs. |
20
- | `blume sync` | Re-fetch remote content sources and regenerate. |
21
- | `blume eject` | Promote the runtime into a standalone Astro app. |
22
- | `blume check` | Type-check the site with `astro check`. |
23
- | `blume doctor` | Diagnose config and content problems. |
24
- | `blume validate` | Validate links across your content. |
12
+ | Command | Description |
13
+ | --- | --- |
14
+ | `blume init` | Scaffold a minimal project. |
15
+ | `blume dev` | Start the dev server with hot reload. |
16
+ | `blume build` | Build the static (or server) site. |
17
+ | `blume preview` | Preview the last build. |
18
+ | `blume add <item>` | Install a source component from the registry. |
19
+ | [`blume migrate <tool>`](/docs/advanced/migrate) | Migrate from Mintlify, Starlight, Nextra, or Fumadocs. |
20
+ | `blume sync` | Re-fetch remote content sources and regenerate. |
21
+ | `blume eject` | Promote the runtime into a standalone Astro app. |
22
+ | `blume check` | Type-check the site with `astro check`. |
23
+ | `blume doctor` | Diagnose config and content problems. |
24
+ | `blume validate` | Validate links across your content. |
25
25
 
26
26
  ## Common flags
27
27
 
@@ -37,25 +37,47 @@ blume <command> [options]
37
37
  - `blume build --output static|server --adapter vercel|node|netlify|cloudflare --base /docs` — override the deployment output, adapter, and base path from `blume.config.ts`.
38
38
  - `blume build --analyze` — print the client JavaScript bundle sizes (largest first) after the build.
39
39
  - `blume build --budget-js <kb> --budget-css <kb>` — fail the build when total client JavaScript/CSS exceeds the budget, turning a performance target into a CI gate.
40
+ - `blume build --isolated` — build into a throwaway `.blume-verify/` runtime (and its own `dist/`) instead of `.blume/`, so a running `blume dev` server and your real `dist/` are left untouched. See [Verifying while the dev server runs](#verifying-while-the-dev-server-runs).
40
41
  - `blume preview --host --port <n>` — bind the preview server.
41
42
  - `blume sync --force` — re-fetch remote sources, dropping the cached snapshot first.
42
43
  - `blume add <item> --force` — overwrite files that already exist.
43
44
  - `blume check --preview` — include drafts and unpublished CMS content when checking.
44
45
  - `blume check --strict` — fail on content diagnostics as well as type errors.
46
+ - `blume check --isolated` — type-check in a throwaway `.blume-verify/` runtime so a running `blume dev` server is left untouched. See [Verifying while the dev server runs](#verifying-while-the-dev-server-runs).
45
47
  - `blume eject --yes` — skip the confirmation prompt.
46
48
  - `blume validate --external` — also check external links over the network.
47
49
  - `blume validate --strict` — exit non-zero on warnings too.
48
- - `blume validate --json` / `blume doctor --json` — emit diagnostics as JSON on
49
- stdout (with `code`, `severity`, `file`, `line`/`column`, and `docsUrl`) for CI
50
- and editor integrations.
50
+ - `blume validate --json` / `blume doctor --json` — emit diagnostics as JSON on stdout (with `code`, `severity`, `file`, `line`/`column`, and `docsUrl`) for CI and editor integrations.
51
+
52
+ ## Verifying while the dev server runs
53
+
54
+ `blume dev` serves a live Astro server rooted at the generated `.blume/` runtime and regenerates it on every change. `blume build` and `blume check` regenerate the _same_ `.blume/`, so running either while the dev server is live would corrupt it — both refuse with an error and exit non-zero:
55
+
56
+ ```
57
+ A `blume dev` server is running against .blume; building would corrupt it.
58
+ Stop the dev server, or re-run with --isolated to build/verify against
59
+ .blume-verify without touching it.
60
+ ```
61
+
62
+ The `--isolated` flag is the escape hatch. It relocates the entire generated runtime (and, for `build`, its output `dist/`) to a sibling `.blume-verify/` directory, so the verification never writes anything the dev server — or your real `dist/` — depends on:
63
+
64
+ ```bash
65
+ # In a second terminal, while `blume dev` is running:
66
+ blume check --isolated # fast: type-check the .astro/config changes
67
+ blume build --isolated # thorough: full production render into .blume-verify/dist
68
+ ```
69
+
70
+ `check --isolated` is the quick path (Astro type + template diagnostics, no `dist/`); `build --isolated` is the heavier one that also catches runtime render errors. Isolated builds skip the deploy post-steps (search index, hosted-provider sync, `llms.txt`, sitemap/robots, redirects) — a verify only needs to confirm the site compiles and renders, not publish it. Blume adds `.blume-verify/` to your `.gitignore` automatically.
71
+
72
+ This is especially useful when a coding agent needs to verify changes while you keep the dev server open. To make plain `blume build`/`blume check` isolate without the flag — for example in an agent's shell — set `BLUME_RUNTIME_DIR` to the runtime directory to use:
73
+
74
+ ```bash
75
+ export BLUME_RUNTIME_DIR=.blume-verify
76
+ ```
51
77
 
52
78
  ## Type-checking
53
79
 
54
- `blume check` runs [`astro check`](https://docs.astro.build/en/reference/cli-reference/#astro-check)
55
- over your project. It regenerates the `.blume` runtime, syncs Astro's content
56
- types, then reports any TypeScript errors — in your `blume.config.ts`, in custom
57
- `.astro` pages, and in the components they import. It exits non-zero when there
58
- are errors, so it works as a `typecheck` step in CI:
80
+ `blume check` runs [`astro check`](https://docs.astro.build/en/reference/cli-reference/#astro-check) over your project. It regenerates the `.blume` runtime, syncs Astro's content types, then reports any TypeScript errors — in your `blume.config.ts`, in custom `.astro` pages, and in the components they import. It exits non-zero when there are errors, so it works as a `typecheck` step in CI:
59
81
 
60
82
  ```json title="package.json"
61
83
  {
@@ -65,8 +87,7 @@ are errors, so it works as a `typecheck` step in CI:
65
87
  }
66
88
  ```
67
89
 
68
- Add a `tsconfig.json` extending Astro's config to your project root so authored
69
- pages resolve `blume/*` imports and virtual modules like `blume:data`:
90
+ Add a `tsconfig.json` extending Astro's config to your project root so authored pages resolve `blume/*` imports and virtual modules like `blume:data`:
70
91
 
71
92
  ```json title="tsconfig.json"
72
93
  {
@@ -81,11 +102,7 @@ Without a project `tsconfig.json`, only the generated runtime is checked.
81
102
 
82
103
  `blume validate` checks every link discovered in your content:
83
104
 
84
- - **Internal page links** (`/guides/intro`, `./sibling`) must resolve to a real
85
- page — broken ones are reported as errors.
86
- - **Anchor links** (`#section`, `/guides/intro#setup`) must match a heading on
87
- the target page — misses are warnings.
105
+ - **Internal page links** (`/guides/intro`, `./sibling`) must resolve to a real page — broken ones are reported as errors.
106
+ - **Anchor links** (`#section`, `/guides/intro#setup`) must match a heading on the target page — misses are warnings.
88
107
  - **Asset links** (`/logo.png`) are checked against the `public/` directory.
89
- - **External links** are only checked with `--external` (off by default since it
90
- requires the network); dead links (404/410/unreachable) are errors, while
91
- rate-limited or transient responses (403/429/5xx/timeout) are warnings.
108
+ - **External links** are only checked with `--external` (off by default since it requires the network); dead links (404/410/unreachable) are errors, while rate-limited or transient responses (403/429/5xx/timeout) are warnings.
@@ -18,6 +18,11 @@ Every page accepts the following frontmatter. All fields are optional.
18
18
  type: "string",
19
19
  description: "Publish date for blog/changelog feeds (ISO or YAML date).",
20
20
  },
21
+ authors: {
22
+ type: "string | string[] | object[]",
23
+ description:
24
+ "Post author(s) for blog/changelog content — a name, or objects with a name plus optional avatar/url and any extra fields. Preserved as-is.",
25
+ },
21
26
  slug: { type: "string", description: "Override the generated slug." },
22
27
  draft: {
23
28
  type: "boolean",
@@ -64,8 +69,7 @@ search:
64
69
 
65
70
  ## Changelog
66
71
 
67
- Changelog entries (`type: changelog`) accept an optional `changelog` object for
68
- richer feed and display metadata:
72
+ Changelog entries (`type: changelog`) accept an optional `changelog` object for richer feed and display metadata:
69
73
 
70
74
  ```yaml lineNumbers
71
75
  type: changelog
@@ -75,8 +79,6 @@ changelog:
75
79
  category: Features
76
80
  ```
77
81
 
78
- `date` may live here or at the top level — both feed the
79
- [changelog RSS feed](/docs/content#feeds). See [Changelog](/docs/advanced/changelog) for the
80
- generated timeline page and feed.
82
+ `date` may live here or at the top level — both feed the [changelog RSS feed](/docs/content#feeds). See [Changelog](/docs/advanced/changelog) for the generated timeline page and feed.
81
83
 
82
84
  Schemas are exported from `blume/schema` for editor and migration tooling.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -50,6 +50,7 @@
50
50
  "./components": "./src/components/index.ts",
51
51
  "./components/*": "./src/components/*",
52
52
  "./core/*": "./src/core/*",
53
+ "./openapi/*": "./src/openapi/*",
53
54
  "./sources/*": "./src/core/sources/*",
54
55
  "./theme/*": "./src/theme/*",
55
56
  "./package.json": "./package.json"
@@ -69,10 +70,19 @@
69
70
  "@astrojs/node": "^11.0.0",
70
71
  "@astrojs/react": "^6.0.0",
71
72
  "@astrojs/vercel": "^11.0.0",
73
+ "@iconify-json/fa6-brands": "^1.2.6",
74
+ "@iconify-json/fa6-regular": "^1.2.4",
75
+ "@iconify-json/fa6-solid": "^1.2.4",
76
+ "@iconify-json/lucide": "^1.2.115",
77
+ "@iconify-json/tabler": "^1.2.35",
78
+ "@iconify/types": "^2.0.0",
79
+ "@iconify/utils": "^3.1.3",
72
80
  "@modelcontextprotocol/sdk": "^1.29.0",
73
81
  "@orama/orama": "^3.1.18",
74
82
  "@pierre/diffs": "^1.2.11",
75
83
  "@scalar/astro": "^0.4.5",
84
+ "@scalar/openapi-parser": "^0.28.8",
85
+ "@scalar/openapi-types": "^0.9.1",
76
86
  "@shikijs/transformers": "^4.2.0",
77
87
  "@shikijs/twoslash": "^4.2.0",
78
88
  "@tailwindcss/typography": "^0.5.20",
@@ -36,11 +36,9 @@ import type { ResolvedConfig } from "../core/schema.ts";
36
36
  import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
37
37
  import type { Navigation } from "../core/types.ts";
38
38
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
39
- import {
40
- buildReferenceFiles,
41
- hasReferences,
42
- referenceTabs,
43
- } from "../openapi/scalar.ts";
39
+ import { hasScalarReferences, referenceTabs } from "../openapi/references.ts";
40
+ import { buildReferenceFiles } from "../openapi/scalar.ts";
41
+ import { isOpenApiSource } from "../openapi/source.ts";
44
42
  import { registry } from "../registry/registry.ts";
45
43
  import { buildSearchDocuments } from "../search/documents.ts";
46
44
  import { searchProviderMeta, servesStaticIndex } from "../search/providers.ts";
@@ -582,8 +580,9 @@ export const buildRuntimeData = (project: BlumeProject): string => {
582
580
 
583
581
  const { i18n } = config;
584
582
 
585
- // API reference routes (Scalar) surface as header tabs alongside the
586
- // content-derived ones, so the reference stays discoverable in every locale.
583
+ // API reference routes surface as header tabs alongside the content-derived
584
+ // ones (Blume-rendered references also own a tab-scoped sidebar of operations),
585
+ // so the reference stays discoverable in every locale.
587
586
  const withReferenceTabs = (nav: Navigation): Navigation => ({
588
587
  ...nav,
589
588
  repoUrl: config.navigation.repo && repoUrl ? repoUrl : null,
@@ -650,6 +649,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
650
649
  })),
651
650
  }
652
651
  : null,
652
+ icons: config.icons,
653
653
  imageZoom: config.markdown.imageZoom,
654
654
  logo: resolveLogo(project),
655
655
  mcp: config.mcp.enabled
@@ -912,6 +912,7 @@ export const generateRuntime = async (
912
912
  const themePath = join(srcDir, "generated", "app.css");
913
913
  const searchClientPath = join(srcDir, "generated", "search-client.ts");
914
914
  const examplesPath = join(srcDir, "generated", "examples.ts");
915
+ const openapiPath = join(srcDir, "generated", "openapi.json");
915
916
 
916
917
  // Record every file this pass writes so orphans (from a now-disabled feature)
917
918
  // can be pruned afterwards. `write` wraps the atomic writer and tracks paths.
@@ -987,6 +988,7 @@ export const generateRuntime = async (
987
988
  needsReact,
988
989
  needsSvelte,
989
990
  needsVue,
991
+ openapiPath,
990
992
  pages,
991
993
  searchClientPath,
992
994
  themePath,
@@ -1235,7 +1237,7 @@ export const generateRuntime = async (
1235
1237
  // React ships with Blume; Vue/Svelte islands need their Astro integration
1236
1238
  // installed by the project. Warn early rather than let Vite fail to resolve it.
1237
1239
  warnings.push(...islandFrameworkWarnings(frameworks, context.root));
1238
- if (hasReferences(config)) {
1240
+ if (hasScalarReferences(config)) {
1239
1241
  const references = await buildReferenceFiles({
1240
1242
  config,
1241
1243
  contentRoutes: new Set(project.graph.pages.map((page) => page.route)),
@@ -1254,6 +1256,14 @@ export const generateRuntime = async (
1254
1256
  join(srcDir, "generated", "data.json"),
1255
1257
  buildRuntimeData(project)
1256
1258
  );
1259
+ // The parsed OpenAPI specs behind the `blume:openapi` alias. Always written
1260
+ // (even as `{}`) so the alias resolves whether or not a reference is enabled;
1261
+ // the source parsed the specs during the scan, so this is just serialization.
1262
+ const openApiSource = project.sources.find(isOpenApiSource);
1263
+ await write(
1264
+ openapiPath,
1265
+ `${JSON.stringify(openApiSource ? openApiSource.openApiData() : {})}\n`
1266
+ );
1257
1267
  await write(
1258
1268
  join(out, "blume.manifest.json"),
1259
1269
  `${JSON.stringify(project.manifest, null, 2)}\n`
@@ -1,10 +1,13 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { fileURLToPath } from "node:url";
2
3
 
3
4
  import type { AstroIntegration } from "astro";
4
5
 
6
+ import type { AssetMount } from "../core/assets.ts";
5
7
  import { enrichDiagnostic } from "../core/diagnostics.ts";
6
8
  import type { Diagnostic } from "../core/types.ts";
7
9
  import { markdownVariantUrl, prefersMarkdown } from "./markdown-negotiation.ts";
10
+ import { copyAssetMounts, serveAssetMounts } from "./static-assets.ts";
8
11
 
9
12
  /** The dev server's HMR channel — either `.ws` (Vite ≤5) or `.hot` (Vite 6+). */
10
13
  interface OverlayChannel {
@@ -72,6 +75,13 @@ export interface BlumeIntegrationOptions {
72
75
  pages: BlumePageRoute[];
73
76
  /** Page routes that have a raw-Markdown variant (the content manifest). */
74
77
  contentRoutes: string[];
78
+ /** Configured `deployment.base`, stripped from dev URLs before matching. */
79
+ base?: string;
80
+ /**
81
+ * `content.assets` mounts: top-level dirs served at the site root in dev and
82
+ * copied into the build output, so root-served assets need no relocation.
83
+ */
84
+ assets?: AssetMount[];
75
85
  }
76
86
 
77
87
  /**
@@ -83,13 +93,13 @@ export interface BlumeIntegrationOptions {
83
93
  * keep serving HTML.
84
94
  */
85
95
  const negotiateMarkdown =
86
- (routes: ReadonlySet<string>) =>
96
+ (routes: ReadonlySet<string>, base?: string) =>
87
97
  (req: IncomingMessage, res: ServerResponse, next: () => void): void => {
88
98
  if (
89
99
  (req.method === "GET" || req.method === "HEAD") &&
90
100
  prefersMarkdown(req.headers.accept)
91
101
  ) {
92
- const variant = markdownVariantUrl(req.url, routes);
102
+ const variant = markdownVariantUrl(req.url, routes, base);
93
103
  if (variant) {
94
104
  res.setHeader("Vary", "Accept");
95
105
  req.url = variant;
@@ -108,6 +118,12 @@ export const blumeIntegration = (
108
118
  options: BlumeIntegrationOptions
109
119
  ): AstroIntegration => ({
110
120
  hooks: {
121
+ "astro:build:done": async ({ dir }) => {
122
+ // Copy in-place asset mounts into the build output, mirroring publicDir.
123
+ if (options.assets?.length) {
124
+ await copyAssetMounts(options.assets, fileURLToPath(dir));
125
+ }
126
+ },
111
127
  "astro:config:setup": ({ injectRoute }) => {
112
128
  for (const page of options.pages) {
113
129
  injectRoute({
@@ -124,9 +140,16 @@ export const blumeIntegration = (
124
140
  // Prepend so the rewrite happens before Astro's own request handler,
125
141
  // letting the rewritten URL resolve to the `.md` endpoint.
126
142
  server.middlewares.stack.unshift({
127
- handle: negotiateMarkdown(new Set(options.contentRoutes)),
143
+ handle: negotiateMarkdown(new Set(options.contentRoutes), options.base),
128
144
  route: "",
129
145
  });
146
+ // Serve `content.assets` mounts (Astro only serves publicDir in dev).
147
+ if (options.assets?.length) {
148
+ server.middlewares.stack.unshift({
149
+ handle: serveAssetMounts(options.assets),
150
+ route: "",
151
+ });
152
+ }
130
153
  },
131
154
  },
132
155
  name: "blume",
@@ -105,9 +105,13 @@ export const discoverIslands = async (
105
105
  continue;
106
106
  }
107
107
  const name = base.replace(ISLAND_FILE, "");
108
- if (!/^[A-Z]/u.test(name)) {
108
+ // The name is used verbatim as both an MDX tag and an unquoted object key
109
+ // in the generated island map, so it must be a plain PascalCase identifier
110
+ // — a `-`, `.`, or space (e.g. `Time-Picker.tsx`) would otherwise emit a
111
+ // syntax-error module and fail the whole build with no pointer to the file.
112
+ if (!/^[A-Z][A-Za-z0-9_]*$/u.test(name)) {
109
113
  warnings.push(
110
- `Island "${file}" must have a PascalCase filename to be used in MDX (e.g. Counter.tsx → <Counter />); skipping it.`
114
+ `Island "${file}" must have a PascalCase identifier filename to be used in MDX (letters, digits, and underscores only, e.g. Counter.tsx → <Counter />); skipping it.`
111
115
  );
112
116
  continue;
113
117
  }
@@ -50,7 +50,8 @@ export const prefersMarkdown = (accept: string | null | undefined): boolean => {
50
50
  */
51
51
  export const markdownVariantUrl = (
52
52
  rawUrl: string | null | undefined,
53
- routes: ReadonlySet<string>
53
+ routes: ReadonlySet<string>,
54
+ base?: string
54
55
  ): string | null => {
55
56
  if (!rawUrl) {
56
57
  return null;
@@ -58,11 +59,24 @@ export const markdownVariantUrl = (
58
59
  const queryIndex = rawUrl.indexOf("?");
59
60
  const query = queryIndex === -1 ? "" : rawUrl.slice(queryIndex);
60
61
  const rawPath = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
62
+
63
+ // A non-root `deployment.base` prefixes the dev-server URL but not the logical
64
+ // content routes, so strip it before matching and re-add it to the variant.
65
+ const prefix = base && base !== "/" ? base.replace(/\/$/u, "") : "";
66
+ let path = rawPath;
67
+ if (prefix) {
68
+ if (path === prefix || path.startsWith(`${prefix}/`)) {
69
+ path = path.slice(prefix.length) || "/";
70
+ } else {
71
+ return null;
72
+ }
73
+ }
74
+
61
75
  const pathname =
62
- rawPath !== "/" && rawPath.endsWith("/") ? rawPath.slice(0, -1) : rawPath;
76
+ path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
63
77
  if (!routes.has(pathname)) {
64
78
  return null;
65
79
  }
66
80
  const target = pathname === "/" ? "/index" : pathname;
67
- return `${target}.md${query}`;
81
+ return `${prefix}${target}.md${query}`;
68
82
  };
@@ -21,7 +21,12 @@ export const discoverPages = async (
21
21
  return files.map((file) => {
22
22
  const rel = relative(pagesRoot, file);
23
23
  const withoutExt = rel.slice(0, rel.length - extname(rel).length);
24
- const parts = withoutExt.split("/").filter((part) => part !== "index");
24
+ const parts = withoutExt.split("/");
25
+ // Only a trailing `index` maps to its parent dir; a folder literally named
26
+ // `index` (e.g. `index/foo.astro`) must keep its segment.
27
+ if (parts.at(-1) === "index") {
28
+ parts.pop();
29
+ }
25
30
  const pattern = parts.length === 0 ? "/" : `/${parts.join("/")}`;
26
31
  return { entrypoint: file, pattern };
27
32
  });
@@ -0,0 +1,117 @@
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
+ };