blume 0.1.0 → 0.1.1

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 (104) hide show
  1. package/dist/cli/index.js +1162 -455
  2. package/dist/cli/index.js.map +32 -28
  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 +3451 -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 +87 -1
  62. package/package.json +13 -3
  63. package/src/ai/llms.ts +1 -2
  64. package/src/astro/examples.ts +104 -0
  65. package/src/astro/generate.ts +197 -69
  66. package/src/astro/islands.ts +1 -1
  67. package/src/astro/pages.ts +59 -0
  68. package/src/astro/templates.ts +136 -9
  69. package/src/cli/commands/validate.ts +1 -0
  70. package/src/components/content/CodeBlock.astro +28 -0
  71. package/src/components/content/Component.astro +65 -0
  72. package/src/components/content/Diff.astro +44 -0
  73. package/src/components/content/Tab.astro +8 -2
  74. package/src/components/content/diff.ts +95 -0
  75. package/src/components/layout/Favicon.astro +11 -4
  76. package/src/components/layout/PageLayout.astro +173 -0
  77. package/src/components/layout/ReferenceLayout.astro +6 -1
  78. package/src/components/layout/RootLayout.astro +9 -5
  79. package/src/components/layout/head-scripts.ts +19 -0
  80. package/src/core/data.ts +128 -0
  81. package/src/core/frontmatter.ts +43 -0
  82. package/src/core/links.ts +26 -10
  83. package/src/core/sources/filesystem.ts +1 -1
  84. package/src/core/sources/mdx-remote.ts +1 -2
  85. package/src/core/sources/mintlify.ts +1 -1
  86. package/src/core/sources/normalize.ts +19 -3
  87. package/src/core/sources/notion.ts +1 -1
  88. package/src/core/sources/sanity.ts +1 -1
  89. package/src/core/tsconfig-aliases.ts +201 -0
  90. package/src/index.ts +12 -0
  91. package/src/markdown/index.ts +69 -0
  92. package/src/migrate/fumadocs/config.ts +53 -4
  93. package/src/migrate/fumadocs/content.ts +1 -1
  94. package/src/migrate/fumadocs/groups.ts +230 -0
  95. package/src/migrate/fumadocs/index.ts +104 -19
  96. package/src/migrate/fumadocs/meta.ts +140 -10
  97. package/src/migrate/mintlify/snippets.ts +2 -1
  98. package/src/migrate/mintlify/transform.ts +1 -2
  99. package/src/migrate/nextra/index.ts +1 -1
  100. package/src/migrate/shared.ts +98 -1
  101. package/src/migrate/starlight/index.ts +1 -1
  102. package/src/registry/eject.ts +57 -10
  103. package/src/search/documents.ts +1 -2
  104. 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,
@@ -17,11 +18,18 @@ 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,29 +87,97 @@ 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
+ };
106
+
107
+ /**
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.
125
+ */
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
+ };
83
130
 
84
131
  /**
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`.
132
+ * Make the generated runtime resolve Astro and its integrations against Blume's
133
+ * own dependency set. Two failure modes this repairs:
134
+ *
135
+ * - Astro is *unreachable* from `.blume/` (workspaces under isolated linkers,
136
+ * pnpm) — the deps live in a store the upward walk can't see.
137
+ * - Astro *resolves to the wrong copy* — a hoisted sibling pinned an older
138
+ * major (e.g. `astro@6` for a type-only import) that shadows Blume's
139
+ * `astro@7`, so `@astrojs/mdx@7` binds to it and crashes the build on a
140
+ * missing export. Resolving merely *an* astro isn't enough; it must be the
141
+ * same one Blume uses.
142
+ *
143
+ * In both cases we symlink Blume's dependency directory in as
144
+ * `.blume/node_modules` so the generated config's bare specifiers (`astro`,
145
+ * `@astrojs/mdx`, …) bind to the matching set. We only do this when those deps
146
+ * are a *co-located, consistent* set (astro beside the `@astrojs/mdx` that binds
147
+ * to it). A split layout — an integration hoisted away from a conflicting astro
148
+ * — can't be made consistent by a single symlink and needs a root `overrides`/
149
+ * `resolutions` pin instead, so we leave it untouched rather than half-fix it.
89
150
  */
90
- const ensureDepsLink = async (outDir: string): Promise<void> => {
91
- if (canResolveAstro(outDir)) {
151
+ export const ensureDepsLink = async (
152
+ outDir: string,
153
+ pkgDir: string = packageRoot()
154
+ ): Promise<void> => {
155
+ const depsDir = blumeDepsDir(pkgDir);
156
+ if (!depsDir || !existsSync(join(depsDir, "@astrojs", "mdx"))) {
92
157
  return;
93
158
  }
94
- if (!existsSync(join(BLUME_NODE_MODULES, "astro"))) {
159
+ // Already correct when `.blume/` resolves the very same astro Blume's deps
160
+ // provide — the clean hoisted case. Otherwise (unreachable, or a different
161
+ // astro shadowing Blume's) link Blume's deps in.
162
+ const blumeAstro = resolvedAstroPath(depsDir);
163
+ if (blumeAstro && resolvedAstroPath(outDir) === blumeAstro) {
95
164
  return;
96
165
  }
97
166
  const link = join(outDir, "node_modules");
98
- if (existsSync(link)) {
99
- return;
167
+ // `lstat`, not `existsSync`, so a broken junction (target since moved) is
168
+ // still detected — `existsSync` follows the link and reports a dangling one
169
+ // as absent. Reaching here means `outDir` doesn't resolve Blume's astro, so
170
+ // any existing link is stale: replace a link we own (a junction/symlink) and
171
+ // leave a real directory untouched.
172
+ const existing = await lstat(link).catch(() => null);
173
+ if (existing) {
174
+ if (!existing.isSymbolicLink()) {
175
+ return;
176
+ }
177
+ await rm(link, { force: true });
100
178
  }
101
179
  await mkdir(outDir, { recursive: true });
102
- await symlink(BLUME_NODE_MODULES, link, "junction");
180
+ await symlink(depsDir, link, "junction");
103
181
  };
104
182
 
105
183
  /** Astro integration package each non-React island framework needs installed. */
@@ -243,20 +321,12 @@ const writeStagedContent = async (
243
321
  };
244
322
 
245
323
  /** 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
324
  /**
255
325
  * Resolve the configured logo. A single SVG is read and inlined so a
256
326
  * `currentColor` logo follows the theme; other images keep their URL for an
257
327
  * `<img>`. The file is looked up under `public/` and the project root.
258
328
  */
259
- const resolveLogo = (project: BlumeProject): ResolvedLogo | null => {
329
+ const resolveLogo = (project: BlumeProject): BlumeLogo | null => {
260
330
  const { logo } = project.config;
261
331
  if (!logo) {
262
332
  return null;
@@ -280,12 +350,6 @@ const resolveLogo = (project: BlumeProject): ResolvedLogo | null => {
280
350
  return { alt, dark, href, light };
281
351
  };
282
352
 
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
353
  /**
290
354
  * Favicon filenames Blume auto-detects, in priority order. Mirrors the Next.js
291
355
  * convention: an `icon.*` or `favicon.*` file in `public/` or the project root
@@ -303,6 +367,8 @@ const FAVICON_CANDIDATES = [
303
367
  /** `<link type>` MIME for the favicon extensions we recognize. */
304
368
  const FAVICON_TYPES: Record<string, string> = {
305
369
  ico: "image/x-icon",
370
+ jpeg: "image/jpeg",
371
+ jpg: "image/jpeg",
306
372
  png: "image/png",
307
373
  svg: "image/svg+xml",
308
374
  };
@@ -318,45 +384,67 @@ const inlineDataUri = (file: string, type: string): string =>
318
384
  `data:${type};base64,${readFileSync(file).toString("base64")}`;
319
385
 
320
386
  /** The bundled Blume favicon, inlined as a data URI so it needs no public file. */
321
- const defaultFavicon = (): ResolvedFavicon => ({
387
+ const defaultFavicon = (): BlumeFavicon => ({
322
388
  href: inlineDataUri(join(BLUME_SRC, "assets", "icon.png"), "image/png"),
323
389
  type: "image/png",
324
390
  });
325
391
 
326
392
  /**
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.
393
+ * Apple touch icon filenames Blume auto-detects, in priority order. Mirrors the
394
+ * Next.js `apple-icon.*` convention (plus the `apple-touch-icon.png` most favicon
395
+ * generators emit): a match in `public/` or the project root becomes the iOS
396
+ * home-screen icon, no config required.
331
397
  */
332
- const resolveFavicon = (project: BlumeProject): ResolvedFavicon => {
398
+ const APPLE_ICON_CANDIDATES = [
399
+ "apple-icon.png",
400
+ "apple-icon.jpg",
401
+ "apple-icon.jpeg",
402
+ "apple-touch-icon.png",
403
+ ];
404
+
405
+ /**
406
+ * Resolve an icon file by convention. A candidate in `public/` is served as-is
407
+ * and referenced by URL; one at the project root is inlined as a data URI (the
408
+ * root isn't a served directory). Returns null when the project ships none.
409
+ */
410
+ const resolveIconFile = (
411
+ project: BlumeProject,
412
+ candidates: string[]
413
+ ): BlumeFavicon | null => {
333
414
  const { root } = project.context;
334
- for (const name of FAVICON_CANDIDATES) {
415
+ for (const name of candidates) {
335
416
  if (existsSync(join(root, "public", name))) {
336
417
  return { href: `/${name}`, type: faviconType(name) };
337
418
  }
338
419
  }
339
- for (const name of FAVICON_CANDIDATES) {
420
+ for (const name of candidates) {
340
421
  const file = join(root, name);
341
422
  if (existsSync(file)) {
342
423
  const type = faviconType(name);
343
424
  return { href: inlineDataUri(file, type ?? "image/x-icon"), type };
344
425
  }
345
426
  }
346
- return defaultFavicon();
427
+ return null;
347
428
  };
348
429
 
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
- }
430
+ /**
431
+ * Resolve the site favicon by convention, falling back to the bundled Blume mark
432
+ * when the project ships no `icon.*`/`favicon.*` file.
433
+ */
434
+ const resolveFavicon = (project: BlumeProject): BlumeFavicon =>
435
+ resolveIconFile(project, FAVICON_CANDIDATES) ?? defaultFavicon();
436
+
437
+ /**
438
+ * Resolve the Apple touch icon by convention, or null when the project ships
439
+ * none (unlike the favicon, there's no bundled default). Note: iOS ignores
440
+ * `data:`-URI apple-touch-icons, so a `public/` file (served by URL) is the
441
+ * reliable path; a root-level file is still inlined for symmetry with favicons.
442
+ */
443
+ const resolveAppleIcon = (project: BlumeProject): BlumeFavicon | null =>
444
+ resolveIconFile(project, APPLE_ICON_CANDIDATES);
357
445
 
358
446
  /** Normalize the banner config (string shorthand or object) for the runtime. */
359
- const resolveBanner = (config: ResolvedConfig): ResolvedBanner | null => {
447
+ const resolveBanner = (config: ResolvedConfig): BlumeBanner | null => {
360
448
  const { banner } = config;
361
449
  if (!banner) {
362
450
  return null;
@@ -436,9 +524,10 @@ export const buildRuntimeData = (project: BlumeProject): string => {
436
524
  )
437
525
  : {};
438
526
 
439
- const data = {
527
+ const data: BlumeData = {
440
528
  config: {
441
529
  analytics: config.analytics ?? null,
530
+ appleIcon: resolveAppleIcon(project),
442
531
  banner: resolveBanner(config),
443
532
  codeWrap: config.markdown.code.wrap,
444
533
  description: config.description,
@@ -463,7 +552,9 @@ export const buildRuntimeData = (project: BlumeProject): string => {
463
552
  mcp: config.mcp.enabled
464
553
  ? { name: config.mcp.name ?? config.title, route: config.mcp.route }
465
554
  : null,
466
- og: { enabled: config.seo.og.enabled },
555
+ // `og.enabled` is resolved to a definite boolean in `loadConfig`; coerce
556
+ // the optional schema type so the serialized shape stays `boolean`.
557
+ og: { enabled: config.seo.og.enabled ?? false },
467
558
  repoUrl,
468
559
  search: {
469
560
  enabled: config.search.provider !== "none",
@@ -619,6 +710,7 @@ export const generateRuntime = async (
619
710
  const dataPath = join(srcDir, "generated", "data.json");
620
711
  const themePath = join(srcDir, "generated", "app.css");
621
712
  const searchClientPath = join(srcDir, "generated", "search-client.ts");
713
+ const examplesPath = join(srcDir, "generated", "examples.ts");
622
714
 
623
715
  // Record every file this pass writes so orphans (from a now-disabled feature)
624
716
  // can be pruned afterwards. `write` wraps the atomic writer and tracks paths.
@@ -633,21 +725,31 @@ export const generateRuntime = async (
633
725
  const askEnabled = config.ai.ask?.enabled ?? false;
634
726
  const exportPdf = config.export.pdf;
635
727
  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),
728
+ const [pages, detectedReact, userTheme, islandDiscovery, exampleDiscovery] =
729
+ await Promise.all([
730
+ context.pagesRoot
731
+ ? discoverPages(context.pagesRoot)
732
+ : Promise.resolve([]),
733
+ detectNeedsReact(context.root),
734
+ readOptional(context.themeFile),
735
+ discoverIslands(context.root),
736
+ discoverExamples(context.root),
737
+ ]);
738
+ // Each island/example framework enables its Astro renderer. React also
739
+ // switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
740
+ // island/example-driven. `.astro` examples need no renderer.
741
+ const frameworks = new Set<string>([
742
+ ...islandDiscovery.islands.map((island) => island.framework),
743
+ ...exampleDiscovery.examples.map((example) => example.framework),
641
744
  ]);
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");
745
+ const needsReact = detectedReact || askEnabled || frameworks.has("react");
746
+ const needsVue = frameworks.has("vue");
747
+ const needsSvelte = frameworks.has("svelte");
748
+
749
+ // Custom pages that should get a generated OG card (the home most of all).
750
+ // Computed before the MCP `.well-known` routes are appended below — those are
751
+ // private and filtered out anyway, but the intent is the user's pages.
752
+ const ogRoutes = customOgRoutes(pages, config.title, config.description);
651
753
 
652
754
  // The hosted MCP server. The `.well-known` discovery docs are injected as
653
755
  // prerendered routes alongside user pages; the server endpoint itself is a
@@ -664,10 +766,12 @@ export const generateRuntime = async (
664
766
  write(
665
767
  join(out, "astro.config.mjs"),
666
768
  astroConfigTemplate({
769
+ aliases: resolveTsconfigAliases(context.root),
667
770
  config,
668
771
  contentRoutes: project.manifest.routes.map((route) => route.path),
669
772
  context,
670
773
  dataPath,
774
+ examplesPath,
671
775
  needsReact,
672
776
  needsSvelte,
673
777
  needsVue,
@@ -705,6 +809,10 @@ export const generateRuntime = async (
705
809
  join(srcDir, "generated", "islands.ts"),
706
810
  islandMapTemplate(islandDiscovery.islands)
707
811
  ),
812
+ write(
813
+ join(srcDir, "generated", "examples.ts"),
814
+ exampleMapTemplate(exampleDiscovery.examples)
815
+ ),
708
816
  write(
709
817
  themePath,
710
818
  tailwindEntryTemplate({
@@ -731,6 +839,22 @@ export const generateRuntime = async (
731
839
  )
732
840
  );
733
841
 
842
+ // Per-example live wrappers for the `examples/` convention, resolved by
843
+ // `<Component path>` through the `examples.ts` map (written above, always).
844
+ await Promise.all(
845
+ exampleDiscovery.examples.map((example) =>
846
+ write(
847
+ join(
848
+ srcDir,
849
+ "generated",
850
+ "examples",
851
+ `${exampleSlug(example.path)}.astro`
852
+ ),
853
+ exampleWrapperTemplate(example)
854
+ )
855
+ )
856
+ );
857
+
734
858
  if (askEnabled) {
735
859
  await write(
736
860
  join(srcDir, "pages", "api", "ask.ts"),
@@ -743,7 +867,7 @@ export const generateRuntime = async (
743
867
  if (config.seo.og.enabled) {
744
868
  await write(
745
869
  join(srcDir, "pages", "og", "[...slug].png.ts"),
746
- ogEndpointTemplate()
870
+ ogEndpointTemplate(ogRoutes)
747
871
  );
748
872
  }
749
873
 
@@ -827,7 +951,11 @@ export const generateRuntime = async (
827
951
 
828
952
  // API/AsyncAPI reference pages (Scalar). One self-contained page per source,
829
953
  // mounted on its configured route and regenerated each run.
830
- const warnings: string[] = [...mcp.warnings, ...islandDiscovery.warnings];
954
+ const warnings: string[] = [
955
+ ...mcp.warnings,
956
+ ...islandDiscovery.warnings,
957
+ ...exampleDiscovery.warnings,
958
+ ];
831
959
 
832
960
  // The new provider SDKs are optional peers; warn (rather than fail opaquely in
833
961
  // Vite) when the configured provider's package isn't installed.
@@ -841,7 +969,7 @@ export const generateRuntime = async (
841
969
 
842
970
  // React ships with Blume; Vue/Svelte islands need their Astro integration
843
971
  // installed by the project. Warn early rather than let Vite fail to resolve it.
844
- warnings.push(...islandFrameworkWarnings(islandFrameworks, context.root));
972
+ warnings.push(...islandFrameworkWarnings(frameworks, context.root));
845
973
  if (hasReferences(config)) {
846
974
  const references = await buildReferenceFiles({
847
975
  config,
@@ -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
+ };