blume 0.1.0 → 0.1.2

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 (106) hide show
  1. package/dist/cli/index.js +1213 -473
  2. package/dist/cli/index.js.map +33 -29
  3. package/dist/types/core/bridge.d.ts +24 -0
  4. package/dist/types/core/config.d.ts +35 -0
  5. package/dist/types/core/data.d.ts +129 -0
  6. package/dist/types/core/define-components.d.ts +27 -0
  7. package/dist/types/core/define-meta.d.ts +16 -0
  8. package/dist/types/core/deployment-env.d.ts +9 -0
  9. package/dist/types/core/diagnostics.d.ts +17 -0
  10. package/dist/types/core/i18n-ui.d.ts +500 -0
  11. package/dist/types/core/load-module.d.ts +7 -0
  12. package/dist/types/core/package-root.d.ts +17 -0
  13. package/dist/types/core/project.d.ts +9 -0
  14. package/dist/types/core/schema.d.ts +3461 -0
  15. package/dist/types/core/sources/types.d.ts +107 -0
  16. package/dist/types/core/types.d.ts +245 -0
  17. package/dist/types/core/ui-packs/ar.d.ts +3 -0
  18. package/dist/types/core/ui-packs/bg.d.ts +3 -0
  19. package/dist/types/core/ui-packs/bn.d.ts +3 -0
  20. package/dist/types/core/ui-packs/ca.d.ts +3 -0
  21. package/dist/types/core/ui-packs/cs.d.ts +3 -0
  22. package/dist/types/core/ui-packs/da.d.ts +3 -0
  23. package/dist/types/core/ui-packs/de.d.ts +3 -0
  24. package/dist/types/core/ui-packs/el.d.ts +3 -0
  25. package/dist/types/core/ui-packs/es.d.ts +3 -0
  26. package/dist/types/core/ui-packs/fa.d.ts +3 -0
  27. package/dist/types/core/ui-packs/fi.d.ts +3 -0
  28. package/dist/types/core/ui-packs/fr.d.ts +3 -0
  29. package/dist/types/core/ui-packs/he.d.ts +3 -0
  30. package/dist/types/core/ui-packs/hi.d.ts +3 -0
  31. package/dist/types/core/ui-packs/hr.d.ts +3 -0
  32. package/dist/types/core/ui-packs/hu.d.ts +3 -0
  33. package/dist/types/core/ui-packs/id.d.ts +3 -0
  34. package/dist/types/core/ui-packs/index.d.ts +13 -0
  35. package/dist/types/core/ui-packs/it.d.ts +3 -0
  36. package/dist/types/core/ui-packs/ja.d.ts +3 -0
  37. package/dist/types/core/ui-packs/ko.d.ts +3 -0
  38. package/dist/types/core/ui-packs/nl.d.ts +3 -0
  39. package/dist/types/core/ui-packs/no.d.ts +3 -0
  40. package/dist/types/core/ui-packs/pl.d.ts +3 -0
  41. package/dist/types/core/ui-packs/pt-br.d.ts +3 -0
  42. package/dist/types/core/ui-packs/pt.d.ts +3 -0
  43. package/dist/types/core/ui-packs/ro.d.ts +3 -0
  44. package/dist/types/core/ui-packs/ru.d.ts +3 -0
  45. package/dist/types/core/ui-packs/sk.d.ts +3 -0
  46. package/dist/types/core/ui-packs/sr.d.ts +3 -0
  47. package/dist/types/core/ui-packs/sv.d.ts +3 -0
  48. package/dist/types/core/ui-packs/th.d.ts +3 -0
  49. package/dist/types/core/ui-packs/tr.d.ts +3 -0
  50. package/dist/types/core/ui-packs/uk.d.ts +3 -0
  51. package/dist/types/core/ui-packs/vi.d.ts +3 -0
  52. package/dist/types/core/ui-packs/zh-tw.d.ts +3 -0
  53. package/dist/types/core/ui-packs/zh.d.ts +3 -0
  54. package/dist/types/core/version.d.ts +8 -0
  55. package/dist/types/index.d.ts +10 -0
  56. package/dist/types/migrate/mintlify/config.d.ts +2 -0
  57. package/dist/types/migrate/mintlify/i18n.d.ts +7 -0
  58. package/dist/types/theme/fonts.d.ts +163 -0
  59. package/docs/advanced/custom-pages.mdx +91 -10
  60. package/docs/configuration/index.mdx +19 -0
  61. package/docs/content/components.mdx +103 -1
  62. package/package.json +13 -3
  63. package/src/ai/llms.ts +1 -2
  64. package/src/astro/examples.ts +106 -0
  65. package/src/astro/generate.ts +300 -75
  66. package/src/astro/index.ts +1 -1
  67. package/src/astro/islands.ts +1 -1
  68. package/src/astro/pages.ts +59 -0
  69. package/src/astro/templates.ts +180 -22
  70. package/src/cli/commands/validate.ts +1 -0
  71. package/src/components/content/CodeBlock.astro +28 -0
  72. package/src/components/content/Component.astro +65 -0
  73. package/src/components/content/Diff.astro +44 -0
  74. package/src/components/content/Tab.astro +8 -2
  75. package/src/components/content/diff.ts +95 -0
  76. package/src/components/layout/Favicon.astro +11 -4
  77. package/src/components/layout/PageLayout.astro +173 -0
  78. package/src/components/layout/ReferenceLayout.astro +6 -1
  79. package/src/components/layout/RootLayout.astro +9 -5
  80. package/src/components/layout/head-scripts.ts +19 -0
  81. package/src/core/data.ts +128 -0
  82. package/src/core/frontmatter.ts +43 -0
  83. package/src/core/links.ts +26 -10
  84. package/src/core/schema.ts +8 -0
  85. package/src/core/sources/filesystem.ts +1 -1
  86. package/src/core/sources/mdx-remote.ts +1 -2
  87. package/src/core/sources/mintlify.ts +1 -1
  88. package/src/core/sources/normalize.ts +19 -3
  89. package/src/core/sources/notion.ts +1 -1
  90. package/src/core/sources/sanity.ts +1 -1
  91. package/src/core/tsconfig-aliases.ts +201 -0
  92. package/src/index.ts +12 -0
  93. package/src/markdown/index.ts +69 -0
  94. package/src/migrate/fumadocs/config.ts +53 -4
  95. package/src/migrate/fumadocs/content.ts +1 -1
  96. package/src/migrate/fumadocs/groups.ts +230 -0
  97. package/src/migrate/fumadocs/index.ts +104 -19
  98. package/src/migrate/fumadocs/meta.ts +140 -10
  99. package/src/migrate/mintlify/snippets.ts +2 -1
  100. package/src/migrate/mintlify/transform.ts +1 -2
  101. package/src/migrate/nextra/index.ts +1 -1
  102. package/src/migrate/shared.ts +98 -1
  103. package/src/migrate/starlight/index.ts +1 -1
  104. package/src/registry/eject.ts +57 -10
  105. package/src/search/documents.ts +1 -2
  106. package/src/theme/entry.ts +29 -0
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
2
  import {
3
+ lstat,
3
4
  mkdir,
4
5
  readFile,
5
6
  rename,
@@ -10,18 +11,25 @@ import {
10
11
  import { createRequire } from "node:module";
11
12
  import { pathToFileURL } from "node:url";
12
13
 
13
- import { dirname, join, normalize, relative } from "pathe";
14
+ import { basename, dirname, join, normalize, relative } from "pathe";
14
15
  import { glob } from "tinyglobby";
15
16
 
16
17
  import { resolveAskBackend } from "../ai/ask.ts";
17
18
  import { buildRawMarkdown } from "../ai/markdown.ts";
18
19
  import { buildMcpData } from "../ai/mcp/data.ts";
19
20
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
21
+ import type {
22
+ BlumeBanner,
23
+ BlumeData,
24
+ BlumeFavicon,
25
+ BlumeLogo,
26
+ } from "../core/data.ts";
20
27
  import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
21
28
  import { resolveFallbackLocale } from "../core/i18n.ts";
22
29
  import { packageRoot } from "../core/package-root.ts";
23
30
  import type { BlumeProject } from "../core/project-graph.ts";
24
31
  import type { ResolvedConfig } from "../core/schema.ts";
32
+ import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
25
33
  import type { Navigation } from "../core/types.ts";
26
34
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
27
35
  import {
@@ -35,8 +43,9 @@ import { tailwindEntryTemplate } from "../theme/entry.ts";
35
43
  import { buildFontsCss, configuredCssVars } from "../theme/fonts.ts";
36
44
  import { buildThemeCss } from "../theme/palette.ts";
37
45
  import { twoslashCss } from "../theme/twoslash.ts";
46
+ import { discoverExamples } from "./examples.ts";
38
47
  import { discoverIslands } from "./islands.ts";
39
- import { discoverPages } from "./pages.ts";
48
+ import { customOgRoutes, discoverPages } from "./pages.ts";
40
49
  import {
41
50
  askEndpointTemplate,
42
51
  astroConfigTemplate,
@@ -44,6 +53,9 @@ import {
44
53
  changelogIndexTemplate,
45
54
  contentConfigTemplate,
46
55
  envTemplate,
56
+ exampleMapTemplate,
57
+ exampleWrapperTemplate,
58
+ exampleSlug,
47
59
  islandMapTemplate,
48
60
  islandWrapperTemplate,
49
61
  mcpEndpointTemplate,
@@ -64,8 +76,6 @@ import {
64
76
 
65
77
  /** Absolute path to the Blume package `src` directory. */
66
78
  const BLUME_SRC = join(packageRoot(), "src");
67
- /** The Blume package's own `node_modules` (where Astro and friends live). */
68
- const BLUME_NODE_MODULES = join(packageRoot(), "node_modules");
69
79
 
70
80
  /** Whether a module specifier resolves from a directory via node resolution. */
71
81
  const canResolveFrom = (fromDir: string, spec: string): boolean => {
@@ -77,31 +87,195 @@ const canResolveFrom = (fromDir: string, spec: string): boolean => {
77
87
  }
78
88
  };
79
89
 
80
- /** Whether Astro resolves from a directory via normal node resolution. */
81
- const canResolveAstro = (fromDir: string): boolean =>
82
- canResolveFrom(fromDir, "astro/package.json");
90
+ /**
91
+ * Realpath of the `astro` package node resolves from a directory, or null when
92
+ * none resolves. Comparing this for `.blume/` against Blume's own deps tells
93
+ * whether the runtime would bind to the *same* astro Blume uses or a different
94
+ * one shadowing it (the hoisted-conflict failure mode).
95
+ */
96
+ const resolvedAstroPath = (fromDir: string): string | null => {
97
+ try {
98
+ const pkg = createRequire(
99
+ pathToFileURL(join(fromDir, "_.js")).href
100
+ ).resolve("astro/package.json");
101
+ return realpathSync(pkg);
102
+ } catch {
103
+ return null;
104
+ }
105
+ };
83
106
 
84
107
  /**
85
- * Make the generated runtime resolve Astro and its integrations. When they are
86
- * hoisted into the project (the usual case for published installs), resolution
87
- * already works. When they are nested and unreachable (workspaces, strict
88
- * package managers), symlink Blume's own dependencies into `.blume`.
108
+ * Locate the directory that holds Blume's installed dependencies (Astro and its
109
+ * integrations).
110
+ *
111
+ * With a clean hoisted install this is moot — the deps sit in a `node_modules`
112
+ * the generated `.blume/` already walks up into, and {@link ensureDepsLink}
113
+ * short-circuits before we need it. But under isolated linkers (Bun's
114
+ * `isolated` mode, pnpm) Blume's deps are NOT hoisted into the project; they
115
+ * live beside the Blume package in a virtual store, invisible to the upward
116
+ * walk from `.blume/`. Two layouts are possible, so probe for `astro`:
117
+ * - `<blume>/node_modules` — deps nested under the package (workspace source)
118
+ * - `dirname(<blume>)` — deps as siblings in the store (isolated/pnpm)
119
+ *
120
+ * `packageRoot()` resolves to Blume's real on-disk path (Node follows the
121
+ * install symlink), so its parent is the store's package directory where the
122
+ * isolated linker places the siblings. The previous fixed
123
+ * `packageRoot()/node_modules` assumption missed the sibling layout entirely,
124
+ * which is why isolated-linker projects had to redeclare Blume's deps by hand.
89
125
  */
90
- const ensureDepsLink = async (outDir: string): Promise<void> => {
91
- if (canResolveAstro(outDir)) {
92
- return;
126
+ export const blumeDepsDir = (pkgDir: string = packageRoot()): string | null => {
127
+ const candidates = [join(pkgDir, "node_modules"), dirname(pkgDir)];
128
+ return candidates.find((dir) => existsSync(join(dir, "astro"))) ?? null;
129
+ };
130
+
131
+ /**
132
+ * Point `link` at Blume's dependency directory via a `node_modules` junction,
133
+ * replacing a stale junction we own and leaving a real directory untouched.
134
+ *
135
+ * `lstat`, not `existsSync`, so a broken junction (target since moved) is still
136
+ * detected — `existsSync` follows the link and reports a dangling one as absent.
137
+ */
138
+ const linkDepsJunction = async (
139
+ link: string,
140
+ depsDir: string
141
+ ): Promise<void> => {
142
+ const existing = await lstat(link).catch(() => null);
143
+ if (existing) {
144
+ if (!existing.isSymbolicLink()) {
145
+ return;
146
+ }
147
+ await rm(link, { force: true });
93
148
  }
94
- if (!existsSync(join(BLUME_NODE_MODULES, "astro"))) {
95
- return;
149
+ await mkdir(dirname(link), { recursive: true });
150
+ await symlink(depsDir, link, "junction");
151
+ };
152
+
153
+ /** Read the `version` field of a `package.json`, or null when unreadable. */
154
+ const readPkgVersion = (pkgJsonPath: string | null): string | null => {
155
+ if (!pkgJsonPath) {
156
+ return null;
96
157
  }
97
- const link = join(outDir, "node_modules");
98
- if (existsSync(link)) {
99
- return;
158
+ try {
159
+ return JSON.parse(readFileSync(pkgJsonPath, "utf-8")).version ?? null;
160
+ } catch {
161
+ return null;
100
162
  }
101
- await mkdir(outDir, { recursive: true });
102
- await symlink(BLUME_NODE_MODULES, link, "junction");
103
163
  };
104
164
 
165
+ /**
166
+ * Build the diagnostic for a split-layout Astro conflict that a symlink can't
167
+ * repair: a different Astro is hoisted to the project root, shadowing Blume's,
168
+ * and `@astrojs/mdx` binds to the wrong copy. `blumeAstroPkg`/`shadowAstroPkg`
169
+ * are the resolved `astro/package.json` paths for Blume's set and the one the
170
+ * runtime actually resolves.
171
+ */
172
+ const astroConflictWarning = (
173
+ blumeAstroPkg: string | null,
174
+ shadowAstroPkg: string | null
175
+ ): string => {
176
+ const blume = readPkgVersion(blumeAstroPkg);
177
+ const shadow = readPkgVersion(shadowAstroPkg);
178
+ const versions =
179
+ blume && shadow
180
+ ? `astro@${shadow} shadowing Blume's astro@${blume}`
181
+ : "a second copy of Astro shadowing Blume's";
182
+ const pin = blume ?? "<Blume's astro version>";
183
+ return `Astro version conflict: another dependency hoisted ${versions} to the project root, so @astrojs/mdx binds to the wrong copy and the build fails on a missing export (e.g. "chunkToString"). A single symlink can't reconcile a split install — pin Blume's Astro by adding a package.json "overrides" (npm/bun/pnpm) or "resolutions" (yarn) entry { "astro": "${pin}" }, then reinstall. Run \`npm ls astro\` to find the dependency pulling the older copy.`;
184
+ };
185
+
186
+ /**
187
+ * Make the generated runtime resolve Astro and its integrations against Blume's
188
+ * own dependency set. Two failure modes this repairs:
189
+ *
190
+ * - Astro is *unreachable* from `.blume/` (workspaces under isolated linkers,
191
+ * pnpm) — the deps live in a store the upward walk can't see.
192
+ * - Astro *resolves to the wrong copy* — a hoisted sibling pinned an older
193
+ * major (e.g. `astro@6` for a type-only import) that shadows Blume's
194
+ * `astro@7`, so `@astrojs/mdx@7` binds to it and crashes the build on a
195
+ * missing export. Resolving merely *an* astro isn't enough; it must be the
196
+ * same one Blume uses.
197
+ *
198
+ * In both cases we symlink Blume's dependency directory in as
199
+ * `.blume/node_modules` so the generated config's bare specifiers (`astro`,
200
+ * `@astrojs/mdx`, …) bind to the matching set. We only do this when those deps
201
+ * are a *co-located, consistent* set (astro beside the `@astrojs/mdx` that binds
202
+ * to it). A split layout — an integration hoisted away from a conflicting astro
203
+ * — can't be made consistent by a single symlink and needs a root `overrides`/
204
+ * `resolutions` pin instead. We can't fix that from `.blume/`, so we return a
205
+ * diagnostic naming the conflict rather than silently shipping a runtime that
206
+ * crashes downstream. Returns the warning, or null when nothing needs saying.
207
+ */
208
+ export const ensureDepsLink = async (
209
+ outDir: string,
210
+ pkgDir: string = packageRoot()
211
+ ): Promise<string | null> => {
212
+ const depsDir = blumeDepsDir(pkgDir);
213
+ if (!depsDir) {
214
+ return null;
215
+ }
216
+ // Already correct when `.blume/` resolves the very same astro Blume's deps
217
+ // provide — the clean hoisted case, nothing to do.
218
+ const blumeAstro = resolvedAstroPath(depsDir);
219
+ const outDirAstro = resolvedAstroPath(outDir);
220
+ if (blumeAstro && outDirAstro === blumeAstro) {
221
+ return null;
222
+ }
223
+ // A co-located, consistent set (astro beside the @astrojs/mdx that binds to
224
+ // it) can be linked in wholesale; this repairs the unreachable and the
225
+ // repairable-conflict cases. Any existing link here is stale and gets
226
+ // replaced.
227
+ if (existsSync(join(depsDir, "@astrojs", "mdx"))) {
228
+ await linkDepsJunction(join(outDir, "node_modules"), depsDir);
229
+ return null;
230
+ }
231
+ // Split layout: Blume's astro is nested (a conflicting astro took the root
232
+ // spot) but @astrojs/mdx hoisted away from it, binding to the shadow. Only a
233
+ // root pin fixes this — surface it.
234
+ return astroConflictWarning(blumeAstro, outDirAstro);
235
+ };
236
+
237
+ /**
238
+ * Vite plugin that makes Blume's externalized runtime deps (zod, shiki, sharp,
239
+ * `@takumi-rs/core`, …) resolvable when Astro executes the static prerender
240
+ * bundle under an isolated linker (Bun's `isolated` mode, pnpm).
241
+ *
242
+ * Astro's static build emits a self-contained SSR bundle to
243
+ * `<outDir>/.prerender/` and `import()`s it in-process to generate the HTML.
244
+ * That bundle externalizes Blume's render-time deps, so Node resolves them at
245
+ * prerender time by walking up from `.prerender/chunks/*.mjs`. {@link
246
+ * ensureDepsLink} only repairs resolution rooted at `.blume/`; `.prerender/`
247
+ * lives under `dist/`, a separate tree an isolated linker never hoists Blume's
248
+ * deps into — so the import dies with `Cannot find package 'zod'`. We drop the
249
+ * same `node_modules` junction into the prerender root, mirroring
250
+ * `.blume/node_modules`, so every externalized specifier — native bindings
251
+ * included, which can't be bundled — resolves. Astro deletes `.prerender/` once
252
+ * generation finishes (and the junction with it: `fs.rm` unlinks symlinks, it
253
+ * never follows them), so nothing leaks into the published `dist/`.
254
+ *
255
+ * Keyed off the output dir's basename (`.prerender`) — the name Astro 7 gives
256
+ * the prerender build for both static (`<outDir>/.prerender/`) and server
257
+ * (`<build.server>/.prerender/`) output — so it fires for exactly that build.
258
+ * Inert in dev, where there is no build and `writeBundle` never runs.
259
+ */
260
+ export const prerenderDepsPlugin = (
261
+ pkgDir: string = packageRoot()
262
+ ): {
263
+ name: string;
264
+ writeBundle: (options: { dir?: string }) => Promise<void>;
265
+ } => ({
266
+ name: "blume:prerender-deps",
267
+ writeBundle: async (options) => {
268
+ if (!options.dir || basename(options.dir) !== ".prerender") {
269
+ return;
270
+ }
271
+ const depsDir = blumeDepsDir(pkgDir);
272
+ if (!depsDir) {
273
+ return;
274
+ }
275
+ await linkDepsJunction(join(options.dir, "node_modules"), depsDir);
276
+ },
277
+ });
278
+
105
279
  /** Astro integration package each non-React island framework needs installed. */
106
280
  const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
107
281
  svelte: "@astrojs/svelte",
@@ -243,20 +417,12 @@ const writeStagedContent = async (
243
417
  };
244
418
 
245
419
  /** The logo shape the runtime consumes: an inline SVG or image URL(s). */
246
- interface ResolvedLogo {
247
- svg?: string;
248
- light?: string;
249
- dark?: string;
250
- alt: string;
251
- href: string;
252
- }
253
-
254
420
  /**
255
421
  * Resolve the configured logo. A single SVG is read and inlined so a
256
422
  * `currentColor` logo follows the theme; other images keep their URL for an
257
423
  * `<img>`. The file is looked up under `public/` and the project root.
258
424
  */
259
- const resolveLogo = (project: BlumeProject): ResolvedLogo | null => {
425
+ const resolveLogo = (project: BlumeProject): BlumeLogo | null => {
260
426
  const { logo } = project.config;
261
427
  if (!logo) {
262
428
  return null;
@@ -280,12 +446,6 @@ const resolveLogo = (project: BlumeProject): ResolvedLogo | null => {
280
446
  return { alt, dark, href, light };
281
447
  };
282
448
 
283
- /** The favicon shape the runtime consumes: a link href plus optional MIME type. */
284
- interface ResolvedFavicon {
285
- href: string;
286
- type?: string;
287
- }
288
-
289
449
  /**
290
450
  * Favicon filenames Blume auto-detects, in priority order. Mirrors the Next.js
291
451
  * convention: an `icon.*` or `favicon.*` file in `public/` or the project root
@@ -303,6 +463,8 @@ const FAVICON_CANDIDATES = [
303
463
  /** `<link type>` MIME for the favicon extensions we recognize. */
304
464
  const FAVICON_TYPES: Record<string, string> = {
305
465
  ico: "image/x-icon",
466
+ jpeg: "image/jpeg",
467
+ jpg: "image/jpeg",
306
468
  png: "image/png",
307
469
  svg: "image/svg+xml",
308
470
  };
@@ -318,45 +480,67 @@ const inlineDataUri = (file: string, type: string): string =>
318
480
  `data:${type};base64,${readFileSync(file).toString("base64")}`;
319
481
 
320
482
  /** The bundled Blume favicon, inlined as a data URI so it needs no public file. */
321
- const defaultFavicon = (): ResolvedFavicon => ({
483
+ const defaultFavicon = (): BlumeFavicon => ({
322
484
  href: inlineDataUri(join(BLUME_SRC, "assets", "icon.png"), "image/png"),
323
485
  type: "image/png",
324
486
  });
325
487
 
326
488
  /**
327
- * Resolve the site favicon by convention. An `icon.*`/`favicon.*` file in
328
- * `public/` is served as-is and referenced by URL; one at the project root is
329
- * inlined as a data URI (the root isn't a served directory). Falls back to the
330
- * bundled Blume mark when the project ships no icon.
489
+ * Apple touch icon filenames Blume auto-detects, in priority order. Mirrors the
490
+ * Next.js `apple-icon.*` convention (plus the `apple-touch-icon.png` most favicon
491
+ * generators emit): a match in `public/` or the project root becomes the iOS
492
+ * home-screen icon, no config required.
493
+ */
494
+ const APPLE_ICON_CANDIDATES = [
495
+ "apple-icon.png",
496
+ "apple-icon.jpg",
497
+ "apple-icon.jpeg",
498
+ "apple-touch-icon.png",
499
+ ];
500
+
501
+ /**
502
+ * Resolve an icon file by convention. A candidate in `public/` is served as-is
503
+ * and referenced by URL; one at the project root is inlined as a data URI (the
504
+ * root isn't a served directory). Returns null when the project ships none.
331
505
  */
332
- const resolveFavicon = (project: BlumeProject): ResolvedFavicon => {
506
+ const resolveIconFile = (
507
+ project: BlumeProject,
508
+ candidates: string[]
509
+ ): BlumeFavicon | null => {
333
510
  const { root } = project.context;
334
- for (const name of FAVICON_CANDIDATES) {
511
+ for (const name of candidates) {
335
512
  if (existsSync(join(root, "public", name))) {
336
513
  return { href: `/${name}`, type: faviconType(name) };
337
514
  }
338
515
  }
339
- for (const name of FAVICON_CANDIDATES) {
516
+ for (const name of candidates) {
340
517
  const file = join(root, name);
341
518
  if (existsSync(file)) {
342
519
  const type = faviconType(name);
343
520
  return { href: inlineDataUri(file, type ?? "image/x-icon"), type };
344
521
  }
345
522
  }
346
- return defaultFavicon();
523
+ return null;
347
524
  };
348
525
 
349
- /** The announcement banner shape the runtime consumes. */
350
- interface ResolvedBanner {
351
- content: string;
352
- link?: { href: string; text: string };
353
- dismissible: boolean;
354
- /** Dismissal key: the configured id, else the content itself. */
355
- key: string;
356
- }
526
+ /**
527
+ * Resolve the site favicon by convention, falling back to the bundled Blume mark
528
+ * when the project ships no `icon.*`/`favicon.*` file.
529
+ */
530
+ const resolveFavicon = (project: BlumeProject): BlumeFavicon =>
531
+ resolveIconFile(project, FAVICON_CANDIDATES) ?? defaultFavicon();
532
+
533
+ /**
534
+ * Resolve the Apple touch icon by convention, or null when the project ships
535
+ * none (unlike the favicon, there's no bundled default). Note: iOS ignores
536
+ * `data:`-URI apple-touch-icons, so a `public/` file (served by URL) is the
537
+ * reliable path; a root-level file is still inlined for symmetry with favicons.
538
+ */
539
+ const resolveAppleIcon = (project: BlumeProject): BlumeFavicon | null =>
540
+ resolveIconFile(project, APPLE_ICON_CANDIDATES);
357
541
 
358
542
  /** Normalize the banner config (string shorthand or object) for the runtime. */
359
- const resolveBanner = (config: ResolvedConfig): ResolvedBanner | null => {
543
+ const resolveBanner = (config: ResolvedConfig): BlumeBanner | null => {
360
544
  const { banner } = config;
361
545
  if (!banner) {
362
546
  return null;
@@ -436,9 +620,10 @@ export const buildRuntimeData = (project: BlumeProject): string => {
436
620
  )
437
621
  : {};
438
622
 
439
- const data = {
623
+ const data: BlumeData = {
440
624
  config: {
441
625
  analytics: config.analytics ?? null,
626
+ appleIcon: resolveAppleIcon(project),
442
627
  banner: resolveBanner(config),
443
628
  codeWrap: config.markdown.code.wrap,
444
629
  description: config.description,
@@ -463,7 +648,9 @@ export const buildRuntimeData = (project: BlumeProject): string => {
463
648
  mcp: config.mcp.enabled
464
649
  ? { name: config.mcp.name ?? config.title, route: config.mcp.route }
465
650
  : null,
466
- og: { enabled: config.seo.og.enabled },
651
+ // `og.enabled` is resolved to a definite boolean in `loadConfig`; coerce
652
+ // the optional schema type so the serialized shape stays `boolean`.
653
+ og: { enabled: config.seo.og.enabled ?? false },
467
654
  repoUrl,
468
655
  search: {
469
656
  enabled: config.search.provider !== "none",
@@ -619,6 +806,7 @@ export const generateRuntime = async (
619
806
  const dataPath = join(srcDir, "generated", "data.json");
620
807
  const themePath = join(srcDir, "generated", "app.css");
621
808
  const searchClientPath = join(srcDir, "generated", "search-client.ts");
809
+ const examplesPath = join(srcDir, "generated", "examples.ts");
622
810
 
623
811
  // Record every file this pass writes so orphans (from a now-disabled feature)
624
812
  // can be pruned afterwards. `write` wraps the atomic writer and tracks paths.
@@ -628,26 +816,36 @@ export const generateRuntime = async (
628
816
  return writeIfChanged(path, content);
629
817
  };
630
818
 
631
- await ensureDepsLink(out);
819
+ const depsLinkWarning = await ensureDepsLink(out);
632
820
 
633
821
  const askEnabled = config.ai.ask?.enabled ?? false;
634
822
  const exportPdf = config.export.pdf;
635
823
  const exportEpub = config.export.epub;
636
- const [pages, detectedReact, userTheme, islandDiscovery] = await Promise.all([
637
- context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
638
- detectNeedsReact(context.root),
639
- readOptional(context.themeFile),
640
- discoverIslands(context.root),
824
+ const [pages, detectedReact, userTheme, islandDiscovery, exampleDiscovery] =
825
+ await Promise.all([
826
+ context.pagesRoot
827
+ ? discoverPages(context.pagesRoot)
828
+ : Promise.resolve([]),
829
+ detectNeedsReact(context.root),
830
+ readOptional(context.themeFile),
831
+ discoverIslands(context.root),
832
+ discoverExamples(context.root, config.examples),
833
+ ]);
834
+ // Each island/example framework enables its Astro renderer. React also
835
+ // switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
836
+ // island/example-driven. `.astro` examples need no renderer.
837
+ const frameworks = new Set<string>([
838
+ ...islandDiscovery.islands.map((island) => island.framework),
839
+ ...exampleDiscovery.examples.map((example) => example.framework),
641
840
  ]);
642
- // Each island's framework enables its Astro renderer. React also switches on
643
- // for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are island-driven.
644
- const islandFrameworks = new Set(
645
- islandDiscovery.islands.map((island) => island.framework)
646
- );
647
- const needsReact =
648
- detectedReact || askEnabled || islandFrameworks.has("react");
649
- const needsVue = islandFrameworks.has("vue");
650
- const needsSvelte = islandFrameworks.has("svelte");
841
+ const needsReact = detectedReact || askEnabled || frameworks.has("react");
842
+ const needsVue = frameworks.has("vue");
843
+ const needsSvelte = frameworks.has("svelte");
844
+
845
+ // Custom pages that should get a generated OG card (the home most of all).
846
+ // Computed before the MCP `.well-known` routes are appended below — those are
847
+ // private and filtered out anyway, but the intent is the user's pages.
848
+ const ogRoutes = customOgRoutes(pages, config.title, config.description);
651
849
 
652
850
  // The hosted MCP server. The `.well-known` discovery docs are injected as
653
851
  // prerendered routes alongside user pages; the server endpoint itself is a
@@ -664,10 +862,12 @@ export const generateRuntime = async (
664
862
  write(
665
863
  join(out, "astro.config.mjs"),
666
864
  astroConfigTemplate({
865
+ aliases: resolveTsconfigAliases(context.root),
667
866
  config,
668
867
  contentRoutes: project.manifest.routes.map((route) => route.path),
669
868
  context,
670
869
  dataPath,
870
+ examplesPath,
671
871
  needsReact,
672
872
  needsSvelte,
673
873
  needsVue,
@@ -705,6 +905,10 @@ export const generateRuntime = async (
705
905
  join(srcDir, "generated", "islands.ts"),
706
906
  islandMapTemplate(islandDiscovery.islands)
707
907
  ),
908
+ write(
909
+ join(srcDir, "generated", "examples.ts"),
910
+ exampleMapTemplate(exampleDiscovery.examples)
911
+ ),
708
912
  write(
709
913
  themePath,
710
914
  tailwindEntryTemplate({
@@ -731,6 +935,22 @@ export const generateRuntime = async (
731
935
  )
732
936
  );
733
937
 
938
+ // Per-example live wrappers for the `examples/` convention, resolved by
939
+ // `<Component path>` through the `examples.ts` map (written above, always).
940
+ await Promise.all(
941
+ exampleDiscovery.examples.map((example) =>
942
+ write(
943
+ join(
944
+ srcDir,
945
+ "generated",
946
+ "examples",
947
+ `${exampleSlug(example.path)}.astro`
948
+ ),
949
+ exampleWrapperTemplate(example)
950
+ )
951
+ )
952
+ );
953
+
734
954
  if (askEnabled) {
735
955
  await write(
736
956
  join(srcDir, "pages", "api", "ask.ts"),
@@ -743,7 +963,7 @@ export const generateRuntime = async (
743
963
  if (config.seo.og.enabled) {
744
964
  await write(
745
965
  join(srcDir, "pages", "og", "[...slug].png.ts"),
746
- ogEndpointTemplate()
966
+ ogEndpointTemplate(ogRoutes)
747
967
  );
748
968
  }
749
969
 
@@ -827,7 +1047,12 @@ export const generateRuntime = async (
827
1047
 
828
1048
  // API/AsyncAPI reference pages (Scalar). One self-contained page per source,
829
1049
  // mounted on its configured route and regenerated each run.
830
- const warnings: string[] = [...mcp.warnings, ...islandDiscovery.warnings];
1050
+ const warnings: string[] = [
1051
+ ...(depsLinkWarning ? [depsLinkWarning] : []),
1052
+ ...mcp.warnings,
1053
+ ...islandDiscovery.warnings,
1054
+ ...exampleDiscovery.warnings,
1055
+ ];
831
1056
 
832
1057
  // The new provider SDKs are optional peers; warn (rather than fail opaquely in
833
1058
  // Vite) when the configured provider's package isn't installed.
@@ -841,7 +1066,7 @@ export const generateRuntime = async (
841
1066
 
842
1067
  // React ships with Blume; Vue/Svelte islands need their Astro integration
843
1068
  // installed by the project. Warn early rather than let Vite fail to resolve it.
844
- warnings.push(...islandFrameworkWarnings(islandFrameworks, context.root));
1069
+ warnings.push(...islandFrameworkWarnings(frameworks, context.root));
845
1070
  if (hasReferences(config)) {
846
1071
  const references = await buildReferenceFiles({
847
1072
  config,
@@ -1,4 +1,4 @@
1
- export { generateRuntime } from "./generate.ts";
1
+ export { generateRuntime, prerenderDepsPlugin } from "./generate.ts";
2
2
  export type { GenerateResult } from "./generate.ts";
3
3
  export { blumeIntegration } from "./integration.ts";
4
4
  export type { BlumeIntegrationOptions, BlumePageRoute } from "./integration.ts";
@@ -53,7 +53,7 @@ const ISLAND_FILE = /\.(?<ext>jsx|svelte|tsx|vue)$/u;
53
53
  const CLIENT_EXPORT =
54
54
  /export\s+const\s+client\s*(?::[^=]+)?=\s*["'](?<mode>\w+)["']/u;
55
55
 
56
- const readClientMode = (
56
+ export const readClientMode = (
57
57
  source: string,
58
58
  file: string,
59
59
  warnings: string[]
@@ -26,3 +26,62 @@ export const discoverPages = async (
26
26
  return { entrypoint: file, pattern };
27
27
  });
28
28
  };
29
+
30
+ /** A custom-page route that should get a generated OG card. */
31
+ export interface OgCustomRoute {
32
+ /** `og/<slug>.png` path segment; `index` for the site root. */
33
+ slug: string;
34
+ /** Card title. */
35
+ title: string;
36
+ /** Small eyebrow line above the title. */
37
+ eyebrow?: string;
38
+ }
39
+
40
+ /** Skip private (`_partial`, `.well-known`) and Astro dynamic (`[param]`) parts. */
41
+ const PRIVATE_SEGMENT = /^[._]/u;
42
+
43
+ const humanizeSegment = (segment: string): string =>
44
+ segment
45
+ .split(/[-_]/u)
46
+ .filter(Boolean)
47
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
48
+ .join(" ");
49
+
50
+ /**
51
+ * Pick the custom-page routes that should get a generated Open Graph card, with
52
+ * the card's slug and text. OG is otherwise content-route only, so a custom page
53
+ * — most importantly the landing `/`, the most-shared URL — would have no card.
54
+ *
55
+ * Dynamic (`[param]`) routes and private segments (`_partials`, `.well-known`)
56
+ * are skipped: they aren't shareable pages. The home is titled with the site
57
+ * title (eyebrow: the site description); a deeper page is titled from its last
58
+ * path segment.
59
+ */
60
+ export const customOgRoutes = (
61
+ pages: BlumePageRoute[],
62
+ siteTitle: string,
63
+ siteDescription?: string
64
+ ): OgCustomRoute[] => {
65
+ const seen = new Set<string>();
66
+ const routes: OgCustomRoute[] = [];
67
+ for (const { pattern } of pages) {
68
+ const segments = pattern.split("/").filter(Boolean);
69
+ if (
70
+ segments.some((part) => PRIVATE_SEGMENT.test(part) || part.includes("["))
71
+ ) {
72
+ continue;
73
+ }
74
+ const slug = segments.length === 0 ? "index" : segments.join("/");
75
+ if (seen.has(slug)) {
76
+ continue;
77
+ }
78
+ seen.add(slug);
79
+ const last = segments.at(-1);
80
+ routes.push(
81
+ last
82
+ ? { eyebrow: siteTitle, slug, title: humanizeSegment(last) }
83
+ : { eyebrow: siteDescription, slug, title: siteTitle }
84
+ );
85
+ }
86
+ return routes;
87
+ };