blume 0.2.0 → 0.4.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 (119) hide show
  1. package/dist/cli/index.js +2429 -792
  2. package/dist/cli/index.js.map +63 -44
  3. package/dist/types/core/data.d.ts +16 -0
  4. package/dist/types/core/define-components.d.ts +9 -2
  5. package/dist/types/core/diagnostics.d.ts +5 -0
  6. package/dist/types/core/schema.d.ts +313 -778
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  9. package/docs/01-quickstart.mdx +5 -16
  10. package/docs/02-deployment.mdx +26 -40
  11. package/docs/advanced/api-reference.mdx +10 -37
  12. package/docs/advanced/blog.mdx +9 -25
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +66 -61
  15. package/docs/configuration/ai.mdx +47 -91
  16. package/docs/configuration/analytics.mdx +20 -38
  17. package/docs/configuration/customization.mdx +92 -27
  18. package/docs/configuration/export.mdx +9 -34
  19. package/docs/configuration/index.mdx +78 -85
  20. package/docs/configuration/search.mdx +17 -54
  21. package/docs/configuration/seo.mdx +18 -44
  22. package/docs/configuration/theming.mdx +20 -42
  23. package/docs/content/components.mdx +42 -101
  24. package/docs/content/i18n.mdx +21 -72
  25. package/docs/content/index.mdx +18 -48
  26. package/docs/content/islands.mdx +79 -33
  27. package/docs/content/meta.mdx +23 -50
  28. package/docs/content/navigation.mdx +42 -56
  29. package/docs/content/sources.mdx +20 -83
  30. package/docs/content/syntax.mdx +37 -105
  31. package/docs/index.mdx +13 -51
  32. package/docs/reference/cli.mdx +49 -18
  33. package/docs/reference/frontmatter.mdx +2 -5
  34. package/package.json +3 -1
  35. package/src/ai/ask-context.ts +131 -0
  36. package/src/ai/ask-data.ts +25 -0
  37. package/src/astro/component-slots.ts +165 -0
  38. package/src/astro/generate.ts +132 -13
  39. package/src/astro/integration.ts +85 -3
  40. package/src/astro/islands.ts +6 -2
  41. package/src/astro/markdown-negotiation.ts +17 -3
  42. package/src/astro/pages.ts +11 -13
  43. package/src/astro/static-assets.ts +117 -0
  44. package/src/astro/templates.ts +120 -50
  45. package/src/blume-modules.d.ts +25 -0
  46. package/src/cli/args.ts +23 -0
  47. package/src/cli/commands/build.ts +209 -1
  48. package/src/cli/commands/check.ts +62 -0
  49. package/src/cli/commands/dev.ts +32 -3
  50. package/src/cli/commands/doctor.ts +32 -6
  51. package/src/cli/commands/eject.ts +3 -1
  52. package/src/cli/commands/init.ts +184 -16
  53. package/src/cli/commands/preview.ts +2 -1
  54. package/src/cli/commands/validate.ts +27 -2
  55. package/src/cli/dev-lock.ts +84 -0
  56. package/src/cli/index.ts +15 -0
  57. package/src/cli/internal-error.ts +63 -0
  58. package/src/cli/log.ts +41 -1
  59. package/src/cli/prepare.ts +17 -3
  60. package/src/cli/required-secrets.ts +44 -0
  61. package/src/components/BlumePage.astro +109 -0
  62. package/src/components/content/YouTube.astro +35 -0
  63. package/src/components/content/youtube.ts +46 -0
  64. package/src/components/index.ts +3 -3
  65. package/src/components/islands/ask-ai.tsx +29 -15
  66. package/src/components/islands/hooks.ts +188 -0
  67. package/src/components/layout/Empty.astro +6 -0
  68. package/src/components/layout/Header.astro +24 -39
  69. package/src/components/layout/Logo.astro +50 -0
  70. package/src/components/layout/NavSelector.astro +75 -0
  71. package/src/components/layout/PageLayout.astro +38 -2
  72. package/src/components/layout/RootLayout.astro +70 -4
  73. package/src/components/layout/hydration-hint.ts +30 -0
  74. package/src/components/layout/overrides.ts +6 -4
  75. package/src/components/props.ts +71 -0
  76. package/src/core/assets.ts +31 -0
  77. package/src/core/bridge.ts +10 -0
  78. package/src/core/builtin-tags.ts +40 -0
  79. package/src/core/component-diagnostics.ts +44 -0
  80. package/src/core/component-overrides.ts +478 -0
  81. package/src/core/config.ts +8 -0
  82. package/src/core/data.ts +14 -0
  83. package/src/core/define-components.ts +9 -2
  84. package/src/core/diagnostics.ts +95 -1
  85. package/src/core/gitignore.ts +30 -0
  86. package/src/core/graph.ts +7 -0
  87. package/src/core/links.ts +60 -19
  88. package/src/core/nav-diagnostics.ts +205 -0
  89. package/src/core/project-graph.ts +40 -1
  90. package/src/core/schema.ts +35 -96
  91. package/src/core/sources/mdx-remote.ts +54 -8
  92. package/src/core/sources/normalize.ts +57 -1
  93. package/src/core/sources/notion.ts +49 -5
  94. package/src/core/sources/sanity.ts +5 -1
  95. package/src/core/types.ts +2 -2
  96. package/src/deploy/redirects.ts +43 -0
  97. package/src/deploy/rss.ts +1 -8
  98. package/src/deploy/sitemap.ts +20 -1
  99. package/src/deploy/xml.ts +8 -0
  100. package/src/markdown/directives.ts +15 -7
  101. package/src/markdown/package-commands.ts +26 -4
  102. package/src/migrate/fumadocs/content.ts +14 -1
  103. package/src/migrate/fumadocs/groups.ts +7 -0
  104. package/src/migrate/fumadocs/index.ts +5 -2
  105. package/src/migrate/mintlify/assets.ts +46 -0
  106. package/src/migrate/mintlify/config.ts +1 -176
  107. package/src/migrate/mintlify/index.ts +53 -45
  108. package/src/migrate/shared.ts +12 -27
  109. package/src/migrate/starlight/config.ts +0 -4
  110. package/src/og/card.ts +175 -38
  111. package/src/registry/eject.ts +52 -12
  112. package/src/registry/registry.ts +172 -0
  113. package/src/registry/rewrite-imports.ts +31 -19
  114. package/src/runtime/index.ts +61 -0
  115. package/src/search/documents.ts +23 -5
  116. package/src/search/sync/algolia.ts +5 -1
  117. package/src/search/sync/typesense.ts +24 -16
  118. package/src/theme/palette.ts +26 -7
  119. package/src/vite-env.d.ts +14 -0
package/src/og/card.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Renderer } from "@takumi-rs/core";
2
- import { container, text } from "@takumi-rs/helpers";
2
+ import { container, image, text } from "@takumi-rs/helpers";
3
+ import type { Node } from "@takumi-rs/helpers";
3
4
 
4
5
  // Reuse one renderer (and its loaded default fonts) across all images.
5
6
  let renderer: Renderer | null = null;
@@ -24,62 +25,198 @@ const resolveAccent = (accent: string): string =>
24
25
  ACCENT_HEX[accent] ?? (accent.startsWith("#") ? accent : "#3b82f6");
25
26
 
26
27
  export interface OgCardOptions {
28
+ /** Large headline — the page title. */
27
29
  title: string;
28
- eyebrow?: string;
30
+ /** Accent color (named preset or hex) for the fallback brand mark. */
29
31
  accent?: string;
32
+ /** Brand/site name shown in the top-left lockup. */
33
+ brand?: string;
34
+ /** Muted subtitle under the headline (usually the site description). */
35
+ description?: string;
36
+ /**
37
+ * Inlined SVG markup of the configured logo (`config.logo.svg`), painted into
38
+ * the brand lockup. Falls back to an accent mark when absent.
39
+ */
40
+ logo?: string;
41
+ /** Footer-left repository slug, e.g. `owner/repo`. */
42
+ repo?: string;
43
+ /** Footer-right site host, e.g. `docs.acme.com`. */
44
+ site?: string;
30
45
  }
31
46
 
32
47
  const WIDTH = 1200;
33
48
  const HEIGHT = 630;
34
49
 
50
+ // Light neutral scale mirrored from the docs homepage theme tokens:
51
+ // FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
52
+ // BORDER = --border.
53
+ const BG = "#fafafa";
54
+ const FOREGROUND = "#0a0a0a";
55
+ const MUTED = "#737373";
56
+ const FAINT = "#a3a3a3";
57
+ const BORDER = "#e5e5e5";
58
+
59
+ /**
60
+ * Truncate to `max` code points with an ellipsis. Slices by code points, not
61
+ * UTF-16 units, so cutting mid-emoji doesn't leave a lone surrogate (a broken
62
+ * glyph) before the ellipsis.
63
+ */
64
+ export const truncate = (value: string, max: number): string => {
65
+ const chars = [...value];
66
+ return chars.length > max
67
+ ? `${chars
68
+ .slice(0, max - 1)
69
+ .join("")
70
+ .trimEnd()}…`
71
+ : value;
72
+ };
73
+
74
+ // Brand mark sizing: target this height, but scale down so a wide wordmark logo
75
+ // stays within the lockup.
76
+ const MARK_HEIGHT = 32;
77
+ const MARK_MAX_WIDTH = 100;
78
+ const VIEW_BOX = /viewBox="0 0 (?<w>[\d.]+) (?<h>[\d.]+)"/u;
79
+
80
+ // Render the configured logo as the brand mark. A `currentColor` logo carries
81
+ // no intrinsic color, so it is painted in the foreground to read on the light
82
+ // card, then handed to Takumi as a data URI sized from the SVG's aspect ratio.
83
+ const logoMark = (svg: string): Node => {
84
+ const painted = svg.replaceAll("currentColor", FOREGROUND);
85
+ const box = painted.match(VIEW_BOX);
86
+ const w = Number(box?.groups?.w);
87
+ const h = Number(box?.groups?.h);
88
+ let height = MARK_HEIGHT;
89
+ let width = w && h ? (MARK_HEIGHT * w) / h : MARK_HEIGHT;
90
+ if (width > MARK_MAX_WIDTH) {
91
+ height = w && h ? (MARK_MAX_WIDTH * h) / w : MARK_HEIGHT;
92
+ width = MARK_MAX_WIDTH;
93
+ }
94
+ return image({
95
+ height: Math.round(height),
96
+ src: `data:image/svg+xml;base64,${Buffer.from(painted).toString("base64")}`,
97
+ width: Math.round(width),
98
+ });
99
+ };
100
+
101
+ // Fallback mark when no SVG logo is configured: an accent tile with the brand's
102
+ // initial, matching the docs favicon aesthetic.
103
+ const initialMark = (accent: string, initial: string): Node =>
104
+ container({
105
+ children: initial
106
+ ? [text(initial, { color: "#ffffff", fontSize: 32, fontWeight: 600 })]
107
+ : [],
108
+ style: {
109
+ alignItems: "center",
110
+ backgroundColor: accent,
111
+ borderRadius: 14,
112
+ display: "flex",
113
+ height: 60,
114
+ justifyContent: "center",
115
+ width: 60,
116
+ },
117
+ });
118
+
119
+ // The headline shrinks as the title grows so it never spills past a couple of
120
+ // lines within the card's content width.
121
+ const titleSize = (title: string): number => {
122
+ if (title.length > 60) {
123
+ return 52;
124
+ }
125
+ if (title.length > 40) {
126
+ return 64;
127
+ }
128
+ return 76;
129
+ };
130
+
35
131
  /** Render a 1200x630 Open Graph card to a PNG buffer. */
36
132
  export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
37
133
  const accent = resolveAccent(options.accent ?? "blue");
134
+ const brand = options.brand?.trim();
135
+ const logo = options.logo?.trim();
136
+ const initial = brand ? brand.charAt(0).toUpperCase() : "";
137
+ const description = options.description?.trim();
138
+ const repo = options.repo?.trim();
139
+ const site = options.site?.trim();
38
140
 
39
- const node = container({
141
+ const header = container({
40
142
  children: [
41
- container({
42
- children: [
43
- container({
44
- style: {
45
- backgroundColor: accent,
46
- borderRadius: 6,
47
- height: 32,
48
- width: 32,
49
- },
50
- }),
51
- options.eyebrow
52
- ? text(options.eyebrow, {
53
- color: "#94a3b8",
54
- fontSize: 30,
55
- })
56
- : container({}),
57
- ],
58
- style: { alignItems: "center", display: "flex", gap: 16 },
59
- }),
60
- text(options.title, {
61
- color: "#f8fafc",
62
- fontSize: 76,
63
- fontWeight: 700,
64
- lineHeight: 1.1,
65
- }),
66
- container({
67
- style: {
68
- backgroundColor: accent,
69
- borderRadius: 4,
70
- height: 8,
71
- width: 120,
72
- },
143
+ logo ? logoMark(logo) : initialMark(accent, initial),
144
+ brand
145
+ ? text(brand, {
146
+ color: FOREGROUND,
147
+ fontSize: 30,
148
+ fontWeight: 500,
149
+ letterSpacing: "-0.01em",
150
+ })
151
+ : container({}),
152
+ ],
153
+ style: { alignItems: "center", display: "flex", gap: 18 },
154
+ });
155
+
156
+ const body = container({
157
+ children: [
158
+ text(truncate(options.title, 64), {
159
+ color: FOREGROUND,
160
+ fontSize: titleSize(options.title),
161
+ fontWeight: 600,
162
+ letterSpacing: "-0.03em",
163
+ lineHeight: 1.05,
164
+ maxWidth: 1010,
165
+ textWrap: "balance",
73
166
  }),
167
+ description
168
+ ? text(truncate(description, 140), {
169
+ color: MUTED,
170
+ fontSize: 30,
171
+ lineHeight: 1.4,
172
+ marginTop: 28,
173
+ maxWidth: 900,
174
+ textWrap: "balance",
175
+ })
176
+ : container({}),
74
177
  ],
178
+ style: { display: "flex", flexDirection: "column" },
179
+ });
180
+
181
+ const footer =
182
+ repo || site
183
+ ? container({
184
+ children: [
185
+ container({
186
+ style: { backgroundColor: BORDER, height: 1, width: "100%" },
187
+ }),
188
+ container({
189
+ children: [
190
+ repo
191
+ ? text(repo, { color: MUTED, fontSize: 22 })
192
+ : container({}),
193
+ site
194
+ ? text(site, { color: FAINT, fontSize: 22 })
195
+ : container({}),
196
+ ],
197
+ style: {
198
+ alignItems: "center",
199
+ display: "flex",
200
+ justifyContent: "space-between",
201
+ marginTop: 28,
202
+ width: "100%",
203
+ },
204
+ }),
205
+ ],
206
+ style: { display: "flex", flexDirection: "column", width: "100%" },
207
+ })
208
+ : container({});
209
+
210
+ const node = container({
211
+ children: [header, body, footer],
75
212
  style: {
76
- backgroundColor: "#0b1020",
77
- color: "#ffffff",
213
+ backgroundColor: BG,
214
+ color: FOREGROUND,
78
215
  display: "flex",
79
216
  flexDirection: "column",
80
217
  height: HEIGHT,
81
218
  justifyContent: "space-between",
82
- padding: 80,
219
+ padding: 72,
83
220
  width: WIDTH,
84
221
  },
85
222
  });
@@ -3,8 +3,10 @@ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
 
4
4
  import { join, relative } from "pathe";
5
5
 
6
+ import { buildAskData } from "../ai/ask-data.ts";
6
7
  import { resolveAskBackend } from "../ai/ask.ts";
7
8
  import { buildRawMarkdown } from "../ai/markdown.ts";
9
+ import { planComponentSlots } from "../astro/component-slots.ts";
8
10
  import { discoverExamples } from "../astro/examples.ts";
9
11
  import {
10
12
  buildRuntimeData,
@@ -32,9 +34,9 @@ import {
32
34
  runtimeTsconfigTemplate,
33
35
  searchClientTemplate,
34
36
  searchEndpointTemplate,
35
- userComponentsTemplate,
36
37
  } from "../astro/templates.ts";
37
38
  import { scanProject } from "../core/project-graph.ts";
39
+ import type { BlumeProject } from "../core/project-graph.ts";
38
40
  import type { ProjectContext } from "../core/types.ts";
39
41
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
40
42
  import { buildReferenceFiles, hasReferences } from "../openapi/scalar.ts";
@@ -46,6 +48,35 @@ import { twoslashCss } from "../theme/twoslash.ts";
46
48
 
47
49
  const POSIX = (path: string): string => path.split("\\").join("/");
48
50
 
51
+ /**
52
+ * The Ask AI endpoint plus, unless the backend runs its own retrieval (Inkeep),
53
+ * its grounding snapshot. Empty when Ask AI is disabled.
54
+ */
55
+ const askFiles = async (
56
+ project: BlumeProject,
57
+ srcDir: string,
58
+ genDir: string
59
+ ): Promise<{ content: string; path: string }[]> => {
60
+ const { ask } = project.config.ai;
61
+ if (!ask?.enabled) {
62
+ return [];
63
+ }
64
+ const grounded = ask.provider !== "inkeep";
65
+ const files = [
66
+ {
67
+ content: askEndpointTemplate(resolveAskBackend(ask), grounded),
68
+ path: join(srcDir, "pages", "api", "ask.ts"),
69
+ },
70
+ ];
71
+ if (grounded) {
72
+ files.push({
73
+ content: `${JSON.stringify(await buildAskData(project))}\n`,
74
+ path: join(genDir, "ask-data.json"),
75
+ });
76
+ }
77
+ return files;
78
+ };
79
+
49
80
  /**
50
81
  * Promote the generated runtime into the project as an owned Astro app. After
51
82
  * eject the project has a normal `astro.config.mjs` and `src/`, the `blume` CLI
@@ -109,7 +140,12 @@ export const eject = async (root: string): Promise<string[]> => {
109
140
  const hasStaged = staged.size > 0;
110
141
  const stagedDir = "blume-staged";
111
142
 
112
- const files: { path: string; content: string }[] = [
143
+ const files: {
144
+ path: string;
145
+ content: string;
146
+ /** Don't overwrite a file the user already owns (e.g. a tuned tsconfig). */
147
+ skipIfExists?: boolean;
148
+ }[] = [
113
149
  {
114
150
  content: astroConfigTemplate({
115
151
  config,
@@ -129,6 +165,8 @@ export const eject = async (root: string): Promise<string[]> => {
129
165
  {
130
166
  content: runtimeTsconfigTemplate(),
131
167
  path: join(root, "tsconfig.json"),
168
+ // Never clobber a hand-tuned tsconfig; only write ours if none exists.
169
+ skipIfExists: true,
132
170
  },
133
171
  { content: envTemplate(), path: join(srcDir, "env.d.ts") },
134
172
  {
@@ -146,11 +184,15 @@ export const eject = async (root: string): Promise<string[]> => {
146
184
  exportEpub,
147
185
  exportPdf,
148
186
  mathEnabled: config.markdown.math,
187
+ needsReact,
149
188
  }),
150
189
  path: join(srcDir, "pages", "[...slug].astro"),
151
190
  },
152
191
  {
153
- content: userComponentsTemplate(componentsImport),
192
+ // Eject keeps the portable re-export form (relative import to the user's
193
+ // components file); hydration/island wrappers would need machine-specific
194
+ // absolute paths, so the ejected app owns and wires those itself.
195
+ content: planComponentSlots(componentsImport, null).module,
154
196
  path: join(genDir, "components.ts"),
155
197
  },
156
198
  // Island/example maps the catch-all imports; written even when empty so the
@@ -192,17 +234,12 @@ export const eject = async (root: string): Promise<string[]> => {
192
234
  ];
193
235
 
194
236
  if (askEnabled) {
195
- files.push({
196
- content: askEndpointTemplate(resolveAskBackend(config.ai.ask)),
197
- path: join(srcDir, "pages", "api", "ask.ts"),
198
- });
237
+ files.push(...(await askFiles(project, srcDir, genDir)));
199
238
  }
200
239
 
201
240
  if (config.seo.og.enabled) {
202
241
  files.push({
203
- content: ogEndpointTemplate(
204
- customOgRoutes(pages, config.title, config.description)
205
- ),
242
+ content: ogEndpointTemplate(customOgRoutes(pages, config.title)),
206
243
  path: join(srcDir, "pages", "og", "[...slug].png.ts"),
207
244
  });
208
245
  }
@@ -297,8 +334,11 @@ export const eject = async (root: string): Promise<string[]> => {
297
334
  files.push({ content, path: join(root, stagedDir, entryId) });
298
335
  }
299
336
 
337
+ const written = files.filter(
338
+ (file) => !(file.skipIfExists && existsSync(file.path))
339
+ );
300
340
  await Promise.all(
301
- files.map(async (file) => {
341
+ written.map(async (file) => {
302
342
  await mkdir(join(file.path, ".."), { recursive: true });
303
343
  await writeFile(file.path, file.content, "utf-8");
304
344
  })
@@ -317,5 +357,5 @@ export const eject = async (root: string): Promise<string[]> => {
317
357
  // The hidden runtime is no longer the source of truth.
318
358
  await rm(context.outDir, { force: true, recursive: true });
319
359
 
320
- return files.map((file) => file.path);
360
+ return written.map((file) => file.path);
321
361
  };
@@ -62,6 +62,171 @@ const layoutComponent = (config: {
62
62
  };
63
63
  };
64
64
 
65
+ /**
66
+ * A built-in MDX content component offered as editable source. Like
67
+ * {@link layoutComponent}, but registered under the `mdx` map (the tag you write
68
+ * in `.mdx`) rather than a `layout` slot.
69
+ */
70
+ const contentComponent = (config: {
71
+ name: string;
72
+ description: string;
73
+ /** Source basename under `src/components/content`. */
74
+ file: string;
75
+ /** MDX tag / component name, also the import name in the post-install hint. */
76
+ tag: string;
77
+ }): RegistryItem => {
78
+ const target = `components/blume/${config.file}`;
79
+ return {
80
+ description: config.description,
81
+ files: [
82
+ {
83
+ rewrite: true,
84
+ source: `components/content/${config.file}`,
85
+ target,
86
+ },
87
+ ],
88
+ name: config.name,
89
+ postInstall: [
90
+ "Register it in components.ts:",
91
+ ' import { defineComponents } from "blume";',
92
+ ` import ${config.tag} from "./${target}";`,
93
+ "",
94
+ ` export default defineComponents({ mdx: { ${config.tag} } });`,
95
+ "",
96
+ "It imports the rest from `blume/*`, so it matches the built-in until you edit it.",
97
+ ],
98
+ };
99
+ };
100
+
101
+ /** Every user-facing content component, by `blume add` name → source basename. */
102
+ const CONTENT_COMPONENTS: {
103
+ name: string;
104
+ description: string;
105
+ file: string;
106
+ tag: string;
107
+ }[] = [
108
+ {
109
+ description: "Aside for notes, tips, and warnings.",
110
+ file: "Callout.astro",
111
+ name: "callout",
112
+ tag: "Callout",
113
+ },
114
+ {
115
+ description: "A linkable card with icon, title, and body.",
116
+ file: "Card.astro",
117
+ name: "card",
118
+ tag: "Card",
119
+ },
120
+ {
121
+ description: "A responsive grid of cards.",
122
+ file: "CardGroup.astro",
123
+ name: "card-group",
124
+ tag: "CardGroup",
125
+ },
126
+ {
127
+ description: "Tabbed code blocks for multiple languages.",
128
+ file: "CodeGroup.astro",
129
+ name: "code-group",
130
+ tag: "CodeGroup",
131
+ },
132
+ {
133
+ description: "A small status/label badge.",
134
+ file: "Badge.astro",
135
+ name: "badge",
136
+ tag: "Badge",
137
+ },
138
+ {
139
+ description: "A numbered list of steps.",
140
+ file: "Steps.astro",
141
+ name: "steps",
142
+ tag: "Steps",
143
+ },
144
+ {
145
+ description: "A single step within Steps.",
146
+ file: "Step.astro",
147
+ name: "step",
148
+ tag: "Step",
149
+ },
150
+ {
151
+ description: "A tabbed content panel.",
152
+ file: "Tabs.astro",
153
+ name: "tabs",
154
+ tag: "Tabs",
155
+ },
156
+ {
157
+ description: "A single tab within Tabs.",
158
+ file: "Tab.astro",
159
+ name: "tab",
160
+ tag: "Tab",
161
+ },
162
+ {
163
+ description: "A collapsible accordion group.",
164
+ file: "Accordion.astro",
165
+ name: "accordion",
166
+ tag: "Accordion",
167
+ },
168
+ {
169
+ description: "A single item within an Accordion.",
170
+ file: "AccordionItem.astro",
171
+ name: "accordion-item",
172
+ tag: "AccordionItem",
173
+ },
174
+ {
175
+ description: "A multi-column layout.",
176
+ file: "Columns.astro",
177
+ name: "columns",
178
+ tag: "Columns",
179
+ },
180
+ {
181
+ description: "A single column within Columns.",
182
+ file: "Column.astro",
183
+ name: "column",
184
+ tag: "Column",
185
+ },
186
+ {
187
+ description: "A bordered frame around an image or embed.",
188
+ file: "Frame.astro",
189
+ name: "frame",
190
+ tag: "Frame",
191
+ },
192
+ {
193
+ description: "An inline expand/collapse disclosure.",
194
+ file: "Expandable.astro",
195
+ name: "expandable",
196
+ tag: "Expandable",
197
+ },
198
+ {
199
+ description: "A titled content panel.",
200
+ file: "Panel.astro",
201
+ name: "panel",
202
+ tag: "Panel",
203
+ },
204
+ {
205
+ description: "A hover tooltip.",
206
+ file: "Tooltip.astro",
207
+ name: "tooltip",
208
+ tag: "Tooltip",
209
+ },
210
+ {
211
+ description: "A compact linkable tile.",
212
+ file: "Tile.astro",
213
+ name: "tile",
214
+ tag: "Tile",
215
+ },
216
+ {
217
+ description: "A styled prompt / terminal block.",
218
+ file: "Prompt.astro",
219
+ name: "prompt",
220
+ tag: "Prompt",
221
+ },
222
+ {
223
+ description: "A responsive, privacy-friendly YouTube embed.",
224
+ file: "YouTube.astro",
225
+ name: "youtube",
226
+ tag: "YouTube",
227
+ },
228
+ ];
229
+
65
230
  /** The built-in, Blume-owned source registry. */
66
231
  export const registry: RegistryItem[] = [
67
232
  layoutComponent({
@@ -94,6 +259,13 @@ export const registry: RegistryItem[] = [
94
259
  name: "pagination",
95
260
  slot: "Pagination",
96
261
  }),
262
+ layoutComponent({
263
+ description: 'The "Was this page helpful?" feedback rating.',
264
+ file: "PageFeedback.astro",
265
+ name: "feedback",
266
+ slot: "Feedback",
267
+ }),
268
+ ...CONTENT_COMPONENTS.map(contentComponent),
97
269
  ];
98
270
 
99
271
  export const findItem = (name: string): RegistryItem | undefined =>
@@ -1,10 +1,16 @@
1
1
  import { dirname, relative, resolve } from "pathe";
2
2
 
3
- // The module specifier in `... from "<spec>"` and side-effect `import "<spec>"`,
4
- // limited to relative specifiers (those starting with `.`). Capturing the
5
- // keyword, gap, and quote lets us rebuild the statement verbatim.
6
- const RELATIVE_IMPORT =
7
- /(?<kw>\bfrom|\bimport)(?<gap>\s+)(?<quote>["'])(?<spec>\.[^"']*)\k<quote>/gu;
3
+ // A relative specifier (starting with `.`) in an `import … from ""` or
4
+ // `export … from "…"` statement. Anchored to the start of a line (`m` flag) and
5
+ // bounded by `[^;]` so it only matches a real statement — not a `from "./…"`
6
+ // that happens to appear inside a string or JSX text — while still allowing a
7
+ // multiline import body between the keyword and `from`.
8
+ const FROM_IMPORT =
9
+ /(?<prefix>^[ \t]*(?:import|export)\b[^;]*?\bfrom[ \t]*)(?<quote>["'])(?<spec>\.[^"']*)\k<quote>/gmu;
10
+
11
+ // A side-effect `import "./…"` at the start of a line.
12
+ const SIDE_EFFECT_IMPORT =
13
+ /(?<prefix>^[ \t]*import[ \t]+)(?<quote>["'])(?<spec>\.[^"']*)\k<quote>/gmu;
8
14
 
9
15
  /**
10
16
  * Rewrite a built-in component's relative imports to `blume/*` package
@@ -22,18 +28,24 @@ export const rewriteImports = (
22
28
  content: string,
23
29
  sourceFile: string,
24
30
  srcRoot: string
25
- ): string =>
26
- content.replaceAll(
27
- RELATIVE_IMPORT,
28
- (match, kw: string, gap: string, quote: string, spec: string) => {
29
- const resolved = resolve(dirname(sourceFile), spec);
30
- if (resolved === sourceFile) {
31
- return match;
32
- }
33
- const rel = relative(srcRoot, resolved);
34
- if (rel.startsWith("..")) {
35
- return match;
36
- }
37
- return `${kw}${gap}${quote}blume/${rel}${quote}`;
31
+ ): string => {
32
+ const rewrite = (
33
+ match: string,
34
+ prefix: string,
35
+ quote: string,
36
+ spec: string
37
+ ): string => {
38
+ const resolved = resolve(dirname(sourceFile), spec);
39
+ if (resolved === sourceFile) {
40
+ return match;
41
+ }
42
+ const rel = relative(srcRoot, resolved);
43
+ if (rel.startsWith("..")) {
44
+ return match;
38
45
  }
39
- );
46
+ return `${prefix}${quote}blume/${rel}${quote}`;
47
+ };
48
+ return content
49
+ .replaceAll(FROM_IMPORT, rewrite)
50
+ .replaceAll(SIDE_EFFECT_IMPORT, rewrite);
51
+ };
@@ -5,6 +5,14 @@
5
5
  * (config, navigation, page collections) without reaching into generated
6
6
  * runtime internals. The surface grows with the customization milestone.
7
7
  */
8
+ import type { BlumeData, BlumeRoute } from "../core/data.ts";
9
+
10
+ export type {
11
+ BlumeData,
12
+ BlumeDataConfig,
13
+ BlumeFeed,
14
+ BlumeRoute,
15
+ } from "../core/data.ts";
8
16
  export type {
9
17
  Heading,
10
18
  NavNode,
@@ -12,3 +20,56 @@ export type {
12
20
  NavTab,
13
21
  PageRecord,
14
22
  } from "../core/types.ts";
23
+
24
+ /** Query for {@link getBlumeCollection}. */
25
+ export interface BlumeCollectionQuery {
26
+ /** Astro collection to read from. Defaults to `"docs"`. */
27
+ collection?: string;
28
+ /** Include drafts, hidden pages, and translation fallbacks. Default `false`. */
29
+ includeHidden?: boolean;
30
+ /** Restrict to a locale code (matches `route.locale`). */
31
+ locale?: string;
32
+ /** Restrict to routes whose path starts with this prefix, e.g. `"/blog"`. */
33
+ prefix?: string;
34
+ }
35
+
36
+ /**
37
+ * Select content routes from the `blume:data` snapshot — for building custom
38
+ * index pages, listings, or feeds without touching generated internals. Pass the
39
+ * imported `data` and an optional query; results are sorted by path.
40
+ *
41
+ * ```astro
42
+ * ---
43
+ * import data from "blume:data";
44
+ * import { getBlumeCollection } from "blume/runtime";
45
+ * const posts = getBlumeCollection(data, { prefix: "/blog" });
46
+ * ---
47
+ * <ul>{posts.map((p) => <li><a href={p.path}>{p.title}</a></li>)}</ul>
48
+ * ```
49
+ */
50
+ export const getBlumeCollection = (
51
+ data: BlumeData,
52
+ query: BlumeCollectionQuery = {}
53
+ ): BlumeRoute[] => {
54
+ const collection = query.collection ?? "docs";
55
+ return data.routes
56
+ .filter((route) => {
57
+ if (route.collection !== collection) {
58
+ return false;
59
+ }
60
+ if (query.locale && route.locale !== query.locale) {
61
+ return false;
62
+ }
63
+ if (query.prefix && !route.path.startsWith(query.prefix)) {
64
+ return false;
65
+ }
66
+ if (
67
+ !query.includeHidden &&
68
+ (route.draft || route.hidden || route.fallback)
69
+ ) {
70
+ return false;
71
+ }
72
+ return true;
73
+ })
74
+ .toSorted((a, b) => a.path.localeCompare(b.path));
75
+ };