blume 0.7.0 → 0.8.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 (168) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1487 -360
  5. package/dist/cli/index.js.map +91 -85
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +9 -0
  9. package/dist/types/core/config-input.d.ts +36 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +2 -0
  12. package/dist/types/core/i18n-ui.d.ts +476 -132
  13. package/dist/types/core/schema.d.ts +216 -145
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/docs/01-quickstart.mdx +5 -2
  17. package/docs/02-deployment.mdx +8 -8
  18. package/docs/03-faq.mdx +46 -16
  19. package/docs/advanced/custom-pages.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +49 -10
  22. package/docs/configuration/customization.mdx +11 -0
  23. package/docs/configuration/index.mdx +27 -3
  24. package/docs/configuration/seo.mdx +2 -2
  25. package/docs/content/components.mdx +1 -1
  26. package/docs/content/i18n.mdx +1 -1
  27. package/docs/content/navigation.mdx +3 -3
  28. package/docs/content/sources.mdx +1 -1
  29. package/docs/content/syntax.mdx +4 -2
  30. package/docs/index.mdx +2 -2
  31. package/docs/reference/cli.mdx +8 -6
  32. package/package.json +14 -4
  33. package/skills/blume/SKILL.md +5 -3
  34. package/skills/blume-update-docs/SKILL.md +3 -2
  35. package/src/ai/agent-readability.ts +9 -8
  36. package/src/ai/ask-context.ts +7 -2
  37. package/src/ai/ask-data.ts +3 -0
  38. package/src/ai/component-markdown.ts +461 -0
  39. package/src/ai/llms.ts +135 -26
  40. package/src/ai/markdown.ts +35 -6
  41. package/src/ai/mcp/data.ts +25 -4
  42. package/src/ai/mcp/discovery.ts +10 -3
  43. package/src/ai/mcp/server.ts +21 -7
  44. package/src/ai/visibility.ts +74 -0
  45. package/src/astro/component-slots.ts +11 -1
  46. package/src/astro/generate.ts +76 -45
  47. package/src/astro/integration.ts +1 -1
  48. package/src/astro/markdown-negotiation.ts +1 -1
  49. package/src/astro/pages.ts +81 -19
  50. package/src/astro/templates.ts +99 -12
  51. package/src/blume-modules.d.ts +8 -0
  52. package/src/cli/commands/build.ts +99 -19
  53. package/src/cli/commands/check.ts +1 -1
  54. package/src/cli/commands/dev.ts +26 -5
  55. package/src/cli/commands/eject.ts +47 -19
  56. package/src/cli/commands/init.ts +120 -180
  57. package/src/cli/commands/preview.ts +4 -1
  58. package/src/cli/commands/validate.ts +43 -2
  59. package/src/cli/dev-lock.ts +8 -4
  60. package/src/cli/eject-scripts.ts +72 -0
  61. package/src/cli/env.ts +15 -5
  62. package/src/cli/init/questions.ts +158 -0
  63. package/src/cli/init/scaffold.ts +380 -0
  64. package/src/components/content/AccordionItem.astro +23 -4
  65. package/src/components/content/Badge.astro +3 -1
  66. package/src/components/content/Card.astro +4 -2
  67. package/src/components/content/Step.astro +10 -1
  68. package/src/components/content/Tabs.astro +15 -3
  69. package/src/components/content/Tile.astro +2 -1
  70. package/src/components/content/Tooltip.astro +3 -1
  71. package/src/components/content/Update.astro +9 -2
  72. package/src/components/content/auto-type-table.ts +7 -1
  73. package/src/components/content/base-href.ts +33 -0
  74. package/src/components/content/changelog-element.ts +9 -2
  75. package/src/components/content/mermaid-element.ts +7 -2
  76. package/src/components/islands/AskAI.astro +5 -2
  77. package/src/components/islands/ask-ai.tsx +56 -6
  78. package/src/components/islands/hooks.ts +28 -8
  79. package/src/components/layout/Banner.astro +10 -2
  80. package/src/components/layout/Header.astro +13 -4
  81. package/src/components/layout/Logo.astro +11 -3
  82. package/src/components/layout/NavTree.astro +17 -3
  83. package/src/components/layout/PageActions.astro +25 -10
  84. package/src/components/layout/PageLayout.astro +45 -8
  85. package/src/components/layout/ReferenceLayout.astro +8 -1
  86. package/src/components/layout/RootLayout.astro +67 -9
  87. package/src/components/layout/Search.astro +94 -22
  88. package/src/components/layout/search/algolia.ts +11 -2
  89. package/src/components/layout/search/endpoint.ts +11 -5
  90. package/src/components/layout/search/orama-cloud.ts +8 -2
  91. package/src/components/layout/search/types.ts +5 -1
  92. package/src/components/layout/search/typesense.ts +4 -1
  93. package/src/components/layout/toc-element.ts +1 -1
  94. package/src/components/openapi/ApiTagOperations.astro +2 -1
  95. package/src/components/openapi/Operation.astro +47 -40
  96. package/src/components/openapi/RequestPanel.astro +1 -1
  97. package/src/components/openapi/helpers.ts +71 -3
  98. package/src/components/openapi/panel.ts +1 -1
  99. package/src/core/base-path.ts +24 -0
  100. package/src/core/builtin-tags.ts +2 -0
  101. package/src/core/config-input.ts +37 -2
  102. package/src/core/config.ts +3 -2
  103. package/src/core/data.ts +2 -0
  104. package/src/core/graph.ts +15 -5
  105. package/src/core/i18n-ui.ts +45 -0
  106. package/src/core/last-modified.ts +13 -6
  107. package/src/core/links.ts +32 -8
  108. package/src/core/navigation.ts +29 -4
  109. package/src/core/package-json.ts +17 -2
  110. package/src/core/project-graph.ts +15 -6
  111. package/src/core/schema.ts +36 -2
  112. package/src/core/sources/assets.ts +6 -1
  113. package/src/core/sources/filesystem.ts +4 -0
  114. package/src/core/sources/mdx-remote.ts +23 -14
  115. package/src/core/sources/normalize.ts +152 -50
  116. package/src/core/sources/notion.ts +8 -8
  117. package/src/core/ui-packs/ar.ts +1 -0
  118. package/src/core/ui-packs/bg.ts +1 -0
  119. package/src/core/ui-packs/bn.ts +1 -0
  120. package/src/core/ui-packs/ca.ts +1 -0
  121. package/src/core/ui-packs/cs.ts +1 -0
  122. package/src/core/ui-packs/da.ts +1 -0
  123. package/src/core/ui-packs/de.ts +1 -0
  124. package/src/core/ui-packs/el.ts +1 -0
  125. package/src/core/ui-packs/es.ts +1 -0
  126. package/src/core/ui-packs/fa.ts +1 -0
  127. package/src/core/ui-packs/fi.ts +1 -0
  128. package/src/core/ui-packs/fr.ts +2 -1
  129. package/src/core/ui-packs/he.ts +1 -0
  130. package/src/core/ui-packs/hi.ts +1 -0
  131. package/src/core/ui-packs/hr.ts +1 -0
  132. package/src/core/ui-packs/hu.ts +1 -0
  133. package/src/core/ui-packs/id.ts +1 -0
  134. package/src/core/ui-packs/it.ts +1 -0
  135. package/src/core/ui-packs/ja.ts +1 -0
  136. package/src/core/ui-packs/ko.ts +1 -0
  137. package/src/core/ui-packs/nl.ts +1 -0
  138. package/src/core/ui-packs/no.ts +1 -0
  139. package/src/core/ui-packs/pl.ts +1 -0
  140. package/src/core/ui-packs/pt-br.ts +1 -0
  141. package/src/core/ui-packs/pt.ts +1 -0
  142. package/src/core/ui-packs/ro.ts +1 -0
  143. package/src/core/ui-packs/ru.ts +1 -0
  144. package/src/core/ui-packs/sk.ts +1 -0
  145. package/src/core/ui-packs/sr.ts +1 -0
  146. package/src/core/ui-packs/sv.ts +1 -0
  147. package/src/core/ui-packs/th.ts +1 -0
  148. package/src/core/ui-packs/tr.ts +1 -0
  149. package/src/core/ui-packs/uk.ts +1 -0
  150. package/src/core/ui-packs/vi.ts +1 -0
  151. package/src/core/ui-packs/zh-tw.ts +1 -0
  152. package/src/core/ui-packs/zh.ts +1 -0
  153. package/src/deploy/adapter-output.ts +18 -8
  154. package/src/deploy/redirects.ts +7 -2
  155. package/src/deploy/sitemap.ts +53 -11
  156. package/src/index.ts +5 -0
  157. package/src/markdown/base-links.ts +10 -8
  158. package/src/markdown/index.ts +15 -3
  159. package/src/markdown/inline-code.ts +7 -2
  160. package/src/markdown/package-commands.ts +10 -4
  161. package/src/openapi/model.ts +12 -4
  162. package/src/openapi/parse.ts +21 -0
  163. package/src/openapi/references.ts +38 -8
  164. package/src/openapi/source.ts +59 -10
  165. package/src/registry/eject.ts +184 -12
  166. package/src/registry/registry.ts +0 -3
  167. package/src/search/documents.ts +34 -2
  168. package/src/seo/jsonld.ts +13 -12
@@ -0,0 +1,158 @@
1
+ import { basename, resolve } from "pathe";
2
+
3
+ import {
4
+ detectPackageManager,
5
+ titleize,
6
+ validateContentDir,
7
+ } from "./scaffold.ts";
8
+ import type {
9
+ InitAnswers,
10
+ PackageManager,
11
+ SourceKind,
12
+ Template,
13
+ } from "./scaffold.ts";
14
+
15
+ /** One prompt option: value plus display label and optional hint. */
16
+ interface PromptOption<Value> {
17
+ hint?: string;
18
+ label: string;
19
+ value: Value;
20
+ }
21
+
22
+ /**
23
+ * The prompt surface `collectAnswers` needs — structurally satisfied by the
24
+ * `@clack/prompts` module itself, and by plain fakes in tests. Every prompt
25
+ * resolves to a cancel symbol when the user aborts.
26
+ */
27
+ export interface Prompter {
28
+ multiselect: (opts: {
29
+ initialValues?: SourceKind[];
30
+ message: string;
31
+ options: PromptOption<SourceKind>[];
32
+ required?: boolean;
33
+ }) => Promise<SourceKind[] | symbol>;
34
+ select: (opts: {
35
+ message: string;
36
+ options: PromptOption<Template>[];
37
+ }) => Promise<Template | symbol>;
38
+ text: (opts: {
39
+ defaultValue?: string;
40
+ initialValue?: string;
41
+ message: string;
42
+ placeholder?: string;
43
+ validate?: (value: string | undefined) => string | undefined;
44
+ }) => Promise<string | symbol>;
45
+ }
46
+
47
+ /** Flag values the user passed explicitly; each one skips its question. */
48
+ export interface InitFlags {
49
+ contentDir?: string;
50
+ directory?: string;
51
+ packageManager?: PackageManager;
52
+ template?: Template;
53
+ }
54
+
55
+ const cancelled = (value: unknown): value is symbol =>
56
+ typeof value === "symbol";
57
+
58
+ /**
59
+ * Run the interactive init flow, returning the collected answers or `null`
60
+ * when the user cancels. All questions run before anything is written, so a
61
+ * cancel never leaves a partial scaffold behind.
62
+ */
63
+ export const collectAnswers = async (
64
+ prompter: Prompter,
65
+ flags: InitFlags,
66
+ defaults: { cwd: string; userAgent?: string }
67
+ ): Promise<InitAnswers | null> => {
68
+ const directory =
69
+ flags.directory ??
70
+ (await prompter.text({
71
+ defaultValue: ".",
72
+ message: "Where should we create your project?",
73
+ placeholder: "./my-docs",
74
+ }));
75
+ if (cancelled(directory)) {
76
+ return null;
77
+ }
78
+
79
+ const root = resolve(defaults.cwd, directory);
80
+ const title = await prompter.text({
81
+ initialValue: titleize(basename(root)),
82
+ message: "What's your docs site called?",
83
+ validate: (value) =>
84
+ value?.trim() ? undefined : "Give your docs site a name.",
85
+ });
86
+ if (cancelled(title)) {
87
+ return null;
88
+ }
89
+
90
+ const template =
91
+ flags.template ??
92
+ (await prompter.select({
93
+ message: "Which template?",
94
+ options: [
95
+ { hint: "Markdown docs site", label: "docs", value: "docs" },
96
+ { hint: "OpenAPI reference at /api", label: "api", value: "api" },
97
+ { hint: "SDK docs with an install page", label: "sdk", value: "sdk" },
98
+ {
99
+ hint: "Docs plus a changelog tab",
100
+ label: "changelog",
101
+ value: "changelog",
102
+ },
103
+ ],
104
+ }));
105
+ if (cancelled(template)) {
106
+ return null;
107
+ }
108
+
109
+ const picked = await prompter.multiselect({
110
+ initialValues: ["filesystem"],
111
+ message: "Where does your content live?",
112
+ options: [
113
+ { hint: "Local .mdx files", label: "filesystem", value: "filesystem" },
114
+ {
115
+ hint: "Changelog from GitHub Releases",
116
+ label: "github-releases",
117
+ value: "github-releases",
118
+ },
119
+ { hint: "A Notion database", label: "notion", value: "notion" },
120
+ { hint: "A Sanity dataset", label: "sanity", value: "sanity" },
121
+ {
122
+ hint: "MDX fetched from a GitHub repo",
123
+ label: "mdx-remote",
124
+ value: "mdx-remote",
125
+ },
126
+ ],
127
+ required: false,
128
+ });
129
+ if (cancelled(picked)) {
130
+ return null;
131
+ }
132
+ // No selection desugars to the implicit local source, same as the schema.
133
+ const sources: SourceKind[] = picked.length === 0 ? ["filesystem"] : picked;
134
+
135
+ let contentDir = flags.contentDir ?? "docs";
136
+ if (flags.contentDir === undefined && sources.includes("filesystem")) {
137
+ const answer = await prompter.text({
138
+ defaultValue: "docs",
139
+ message: "Content directory?",
140
+ placeholder: "docs",
141
+ validate: (value) => validateContentDir(root, value || "docs"),
142
+ });
143
+ if (cancelled(answer)) {
144
+ return null;
145
+ }
146
+ contentDir = answer;
147
+ }
148
+
149
+ return {
150
+ contentDir,
151
+ directory,
152
+ packageManager:
153
+ flags.packageManager ?? detectPackageManager(defaults.userAgent),
154
+ sources,
155
+ template,
156
+ title,
157
+ };
158
+ };
@@ -0,0 +1,380 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+
4
+ import { basename, dirname, isAbsolute, join, relative } from "pathe";
5
+
6
+ import { blumePackageJson, toPackageName } from "../../core/package-json.ts";
7
+
8
+ export const TEMPLATES = ["docs", "api", "sdk", "changelog"] as const;
9
+ export type Template = (typeof TEMPLATES)[number];
10
+
11
+ export const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
12
+ export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
13
+
14
+ /** The content-source kinds `init` can scaffold a config block for. */
15
+ export const SOURCE_KINDS = [
16
+ "filesystem",
17
+ "github-releases",
18
+ "notion",
19
+ "sanity",
20
+ "mdx-remote",
21
+ ] as const;
22
+ export type SourceKind = (typeof SOURCE_KINDS)[number];
23
+
24
+ /** Everything the scaffolder needs, whether prompted or derived from flags. */
25
+ export interface InitAnswers {
26
+ contentDir: string;
27
+ /** Target directory as the user gave it (`.` = current directory). */
28
+ directory: string;
29
+ packageManager: PackageManager;
30
+ sources: SourceKind[];
31
+ template: Template;
32
+ title: string;
33
+ }
34
+
35
+ /** One file the scaffold plan will write; `path` is absolute. */
36
+ export interface ScaffoldFile {
37
+ content: string;
38
+ path: string;
39
+ }
40
+
41
+ /** Log sink for scaffold output — satisfied by both consola and clack's `log`. */
42
+ export interface ScaffoldLog {
43
+ info: (message: string) => void;
44
+ success: (message: string) => void;
45
+ }
46
+
47
+ /** A starter: a config fragment plus seed content files (paths relative to root). */
48
+ interface Starter {
49
+ configExtra: string;
50
+ files: (contentDir: string) => { content: string; path: string }[];
51
+ }
52
+
53
+ const page = (title: string, description: string, body: string): string =>
54
+ `---\ntitle: ${title}\ndescription: ${description}\n---\n\n${body}\n`;
55
+
56
+ export const STARTERS: Record<Template, Starter> = {
57
+ api: {
58
+ configExtra: `
59
+ openapi: {
60
+ enabled: true,
61
+ route: "/api",
62
+ sources: [
63
+ {
64
+ label: "Petstore",
65
+ spec: "https://petstore3.swagger.io/api/v3/openapi.json",
66
+ },
67
+ ],
68
+ },`,
69
+ files: (dir) => [
70
+ {
71
+ content: page(
72
+ "API Reference",
73
+ "Explore the API.",
74
+ "# API Reference\n\nYour OpenAPI spec renders at [`/api`](/api). Point `openapi.sources` at your own spec in `blume.config.ts`."
75
+ ),
76
+ path: join(dir, "index.mdx"),
77
+ },
78
+ ],
79
+ },
80
+ changelog: {
81
+ configExtra: `
82
+ navigation: {
83
+ tabs: [
84
+ { label: "Docs", path: "/" },
85
+ { label: "Changelog", path: "/changelog" },
86
+ ],
87
+ },`,
88
+ files: (dir) => [
89
+ {
90
+ content: page(
91
+ "Introduction",
92
+ "Welcome to your new Blume docs.",
93
+ "# Introduction\n\nWrite your docs here, and log releases under `changelog/`."
94
+ ),
95
+ path: join(dir, "index.mdx"),
96
+ },
97
+ {
98
+ content: `---\ntitle: v1.0.0\ntype: changelog\ndate: 2026-01-01\n---\n\nThe first release. Edit \`${dir}/changelog/v1-0-0.mdx\` or add new entries beside it.\n`,
99
+ path: join(dir, "changelog", "v1-0-0.mdx"),
100
+ },
101
+ ],
102
+ },
103
+ docs: {
104
+ configExtra: "",
105
+ files: (dir) => [
106
+ {
107
+ content: page(
108
+ "Introduction",
109
+ "Welcome to your new Blume docs.",
110
+ `# Introduction\n\nWelcome to **Blume** — markdown-first docs powered by Astro and Vite.\n\nEdit \`${dir}/index.mdx\` to get started, then run \`blume dev\`.`
111
+ ),
112
+ path: join(dir, "index.mdx"),
113
+ },
114
+ ],
115
+ },
116
+ sdk: {
117
+ configExtra: "",
118
+ files: (dir) => [
119
+ {
120
+ content: page(
121
+ "Introduction",
122
+ "Get started with the SDK.",
123
+ "# Introduction\n\nInstall the SDK and make your first call. See [Installation](/installation)."
124
+ ),
125
+ path: join(dir, "index.mdx"),
126
+ },
127
+ {
128
+ content: page(
129
+ "Installation",
130
+ "Install the SDK.",
131
+ "# Installation\n\n```package-install\nyour-sdk\n```"
132
+ ),
133
+ path: join(dir, "installation.mdx"),
134
+ },
135
+ ],
136
+ },
137
+ };
138
+
139
+ /**
140
+ * Install + dev commands to print for the chosen package manager, plus the
141
+ * prefix that runs a locally installed bin (`exec`, e.g. `npx blume eject`) —
142
+ * dependency bins aren't on PATH, so a bare `blume …` hint would not run.
143
+ */
144
+ export const commandsFor = (
145
+ pm: PackageManager
146
+ ): { build: string; dev: string; exec: string; install: string } => ({
147
+ // `bun build` invokes Bun's bundler, not the package.json `build` script —
148
+ // unlike `bun dev`, the script name is shadowed by a builtin subcommand.
149
+ build: pm === "npm" || pm === "bun" ? `${pm} run build` : `${pm} build`,
150
+ dev: pm === "npm" ? "npm run dev" : `${pm} dev`,
151
+ exec: { bun: "bunx", npm: "npx", pnpm: "pnpm exec", yarn: "yarn" }[pm],
152
+ install: `${pm} install`,
153
+ });
154
+
155
+ /**
156
+ * Derive the package manager from an npm user-agent string (the first
157
+ * `name/version` token of `npm_config_user_agent`), falling back to npm.
158
+ */
159
+ export const detectPackageManager = (userAgent?: string): PackageManager => {
160
+ const name = userAgent?.split("/")[0] as PackageManager | undefined;
161
+ return name !== undefined && PACKAGE_MANAGERS.includes(name) ? name : "npm";
162
+ };
163
+
164
+ /**
165
+ * The content dir is joined into every scaffolded file path, so an absolute or
166
+ * `../`-escaping value would write outside the project. Returns an error
167
+ * message, or `undefined` when the dir is safe.
168
+ */
169
+ export const validateContentDir = (
170
+ root: string,
171
+ dir: string
172
+ ): string | undefined =>
173
+ isAbsolute(dir) || relative(root, join(root, dir)).startsWith("..")
174
+ ? "Must be a relative path inside the project."
175
+ : undefined;
176
+
177
+ /** Turn a directory name into a display title: `my-docs` → `My Docs`. */
178
+ export const titleize = (raw: string): string => {
179
+ const words = raw
180
+ .replaceAll(/[-_.]+/gu, " ")
181
+ .split(/\s+/u)
182
+ .filter(Boolean);
183
+ return words.length === 0
184
+ ? "My Docs"
185
+ : words
186
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
187
+ .join(" ");
188
+ };
189
+
190
+ /** True when any selected source is remote (everything except `filesystem`). */
191
+ const hasRemoteSource = (sources: SourceKind[]): boolean =>
192
+ sources.some((source) => source !== "filesystem");
193
+
194
+ /**
195
+ * Config snippets for each remote source kind, with placeholder values to
196
+ * replace and comments naming the env var each source authenticates with.
197
+ */
198
+ const sourceSnippetFor = (kind: Exclude<SourceKind, "filesystem">): string => {
199
+ switch (kind) {
200
+ case "github-releases": {
201
+ return ` // Changelog entries from GitHub Releases. Private repos read
202
+ // GITHUB_TOKEN from the environment.
203
+ {
204
+ type: "github-releases",
205
+ owner: "your-org",
206
+ repo: "your-repo",
207
+ prefix: "changelog",
208
+ },`;
209
+ }
210
+ case "notion": {
211
+ return ` // Pages from a Notion database. Reads NOTION_TOKEN from the environment.
212
+ {
213
+ type: "notion",
214
+ database: "your-database-id",
215
+ prefix: "notion",
216
+ },`;
217
+ }
218
+ case "sanity": {
219
+ return ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
220
+ // from the environment.
221
+ {
222
+ type: "sanity",
223
+ projectId: "your-project-id",
224
+ dataset: "production",
225
+ query: \`*[_type == "doc"]\`,
226
+ prefix: "sanity",
227
+ },`;
228
+ }
229
+ case "mdx-remote": {
230
+ return ` // MDX fetched from a GitHub repo. Private repos read GITHUB_TOKEN
231
+ // from the environment.
232
+ {
233
+ type: "mdx-remote",
234
+ github: { owner: "your-org", repo: "your-repo", path: "docs" },
235
+ prefix: "remote",
236
+ },`;
237
+ }
238
+ default: {
239
+ return kind satisfies never;
240
+ }
241
+ }
242
+ };
243
+
244
+ /**
245
+ * The `content` block for the generated config, or an empty string when the
246
+ * defaults (filesystem source, `docs` root) apply — keeping the default
247
+ * scaffold byte-identical to a config with no `content` key at all.
248
+ */
249
+ const contentBlockFor = (answers: InitAnswers): string => {
250
+ const sources =
251
+ answers.sources.length === 0 ? ["filesystem" as const] : answers.sources;
252
+ if (!hasRemoteSource(sources)) {
253
+ return answers.contentDir === "docs"
254
+ ? ""
255
+ : `
256
+ content: {
257
+ root: ${JSON.stringify(answers.contentDir)},
258
+ },`;
259
+ }
260
+ // Explicit sources replace the implicit filesystem desugar, so the local
261
+ // content dir must be listed alongside the remote sources to stay included.
262
+ const entries = SOURCE_KINDS.filter((kind) => sources.includes(kind)).map(
263
+ (kind) =>
264
+ kind === "filesystem"
265
+ ? ` { type: "filesystem", root: ${JSON.stringify(answers.contentDir)} },`
266
+ : sourceSnippetFor(kind)
267
+ );
268
+ return `
269
+ content: {
270
+ sources: [
271
+ ${entries.join("\n")}
272
+ ],
273
+ },`;
274
+ };
275
+
276
+ /** The full `blume.config.ts` text for the chosen answers. */
277
+ export const buildConfig = (
278
+ answers: InitAnswers
279
+ ): string => `import { defineConfig } from "blume";
280
+
281
+ export default defineConfig({
282
+ title: ${JSON.stringify(answers.title)},
283
+ description: "Documentation powered by Blume.",${STARTERS[answers.template].configExtra}${contentBlockFor(answers)}
284
+ });
285
+ `;
286
+
287
+ /** SDK dependencies required by the selected remote sources. */
288
+ const extraDepsFor = (sources: SourceKind[]): Record<string, string> => ({
289
+ ...(sources.includes("notion") && { "@notionhq/client": "^2.2.15" }),
290
+ ...(sources.includes("sanity") && { "@sanity/client": "^6.21.0" }),
291
+ });
292
+
293
+ /** Every file `init` should write for the given answers, package.json first. */
294
+ export const buildPlan = (
295
+ root: string,
296
+ answers: InitAnswers
297
+ ): ScaffoldFile[] => {
298
+ const files: ScaffoldFile[] = [
299
+ {
300
+ content: blumePackageJson(
301
+ toPackageName(basename(root)),
302
+ extraDepsFor(answers.sources)
303
+ ),
304
+ path: join(root, "package.json"),
305
+ },
306
+ { content: buildConfig(answers), path: join(root, "blume.config.ts") },
307
+ ];
308
+ // Seed pages only make sense when a local filesystem source will read them.
309
+ if (answers.sources.length === 0 || answers.sources.includes("filesystem")) {
310
+ files.push(
311
+ ...STARTERS[answers.template]
312
+ .files(answers.contentDir)
313
+ .map((file) => ({ ...file, path: join(root, file.path) }))
314
+ );
315
+ }
316
+ return files;
317
+ };
318
+
319
+ const writeFileSafe = async (
320
+ file: ScaffoldFile,
321
+ log: ScaffoldLog
322
+ ): Promise<boolean> => {
323
+ if (existsSync(file.path)) {
324
+ log.info(`Skipped existing ${file.path}`);
325
+ return false;
326
+ }
327
+ await mkdir(dirname(file.path), { recursive: true });
328
+ await writeFile(file.path, file.content, "utf-8");
329
+ log.success(`Created ${file.path}`);
330
+ return true;
331
+ };
332
+
333
+ /**
334
+ * Write the plan's files, skipping any that already exist. Reports whether a
335
+ * `package.json` was newly created (it decides the install next-step).
336
+ */
337
+ export const applyPlan = async (
338
+ files: ScaffoldFile[],
339
+ log: ScaffoldLog
340
+ ): Promise<{ createdPackage: boolean }> => {
341
+ const created = await Promise.all(
342
+ files.map((file) => writeFileSafe(file, log))
343
+ );
344
+ const createdPackage = files.some(
345
+ (file, index) => created[index] && basename(file.path) === "package.json"
346
+ );
347
+ return { createdPackage };
348
+ };
349
+
350
+ /** Env vars the selected sources read, in a stable order. */
351
+ const envVarsFor = (sources: SourceKind[]): string[] =>
352
+ [
353
+ ["GITHUB_TOKEN", ["github-releases", "mdx-remote"]] as const,
354
+ ["NOTION_TOKEN", ["notion"]] as const,
355
+ ["SANITY_TOKEN", ["sanity"]] as const,
356
+ ]
357
+ .filter(([, kinds]) => kinds.some((kind) => sources.includes(kind)))
358
+ .map(([envVar]) => envVar);
359
+
360
+ /** The next-steps message: `cd` hint, install/dev commands, and token setup. */
361
+ export const nextSteps = (
362
+ answers: InitAnswers,
363
+ createdPackage: boolean
364
+ ): string => {
365
+ const commands = commandsFor(answers.packageManager);
366
+ const lines: string[] = [];
367
+ if (answers.directory !== ".") {
368
+ lines.push(`cd ${answers.directory}`);
369
+ }
370
+ if (createdPackage) {
371
+ lines.push(commands.install);
372
+ }
373
+ lines.push(commands.dev);
374
+ const envVars = envVarsFor(answers.sources);
375
+ const auth =
376
+ envVars.length > 0
377
+ ? `\nSet ${envVars.join(" and ")} in .env.local so your sources can authenticate.\n`
378
+ : "";
379
+ return `Next steps:\n\n ${lines.join("\n ")}\n${auth}`;
380
+ };
@@ -67,6 +67,22 @@ const accordionId = id ?? slugify(title);
67
67
  if (!state.__blumeAccordionHash) {
68
68
  state.__blumeAccordionHash = true;
69
69
 
70
+ // Repeated titles slug to the same id (e.g. several <Expandable> blocks all
71
+ // defaulting to "show-more"); suffix the later ones so ids stay unique. The
72
+ // first keeps the plain slug, so hash deep-links stay stable.
73
+ const dedupeAccordionIds = () => {
74
+ const seen = new Map<string, number>();
75
+ for (const accordion of document.querySelectorAll<HTMLElement>(
76
+ "[data-blume-accordion][id]"
77
+ )) {
78
+ const count = seen.get(accordion.id) ?? 0;
79
+ seen.set(accordion.id, count + 1);
80
+ if (count > 0) {
81
+ accordion.id = `${accordion.id}-${count + 1}`;
82
+ }
83
+ }
84
+ };
85
+
70
86
  const accordionFromHash = () => {
71
87
  const id = decodeURIComponent(window.location.hash.slice(1));
72
88
  if (!id) {
@@ -106,13 +122,16 @@ const accordionId = id ?? slugify(title);
106
122
  true
107
123
  );
108
124
 
125
+ const init = () => {
126
+ dedupeAccordionIds();
127
+ openHashAccordion();
128
+ };
129
+
109
130
  window.addEventListener("hashchange", openHashAccordion);
110
131
  if (document.readyState === "loading") {
111
- document.addEventListener("DOMContentLoaded", openHashAccordion, {
112
- once: true,
113
- });
132
+ document.addEventListener("DOMContentLoaded", init, { once: true });
114
133
  } else {
115
- openHashAccordion();
134
+ init();
116
135
  }
117
136
  }
118
137
  </script>
@@ -97,10 +97,12 @@ const resolvedClass = astroClass ?? className;
97
97
  const colorClass = activeStroke
98
98
  ? strokeColorClass[colorName] ?? strokeColorClass.gray
99
99
  : filledColorClass[colorName] ?? filledColorClass.gray;
100
+ // `color-mix` instead of appending a hex alpha byte, which breaks shorthand
101
+ // hex (`#f00` + `20` is not a color); 12% matches the old `20` (32/255) alpha.
100
102
  const customStyle = customColor
101
103
  ? activeStroke
102
104
  ? `border-color:${customColor};color:${customColor}`
103
- : `background:${customColor}20;color:${customColor}`
105
+ : `background:color-mix(in srgb, ${customColor} 12%, transparent);color:${customColor}`
104
106
  : undefined;
105
107
  ---
106
108
 
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import Icon from "../Icon.astro";
3
+ import { withBase } from "../islands/base-path.ts";
4
+ import { contentHref } from "./base-href.ts";
3
5
 
4
6
  interface Props {
5
7
  title?: string;
@@ -54,7 +56,7 @@ const variantClass = {
54
56
  isHorizontal ? "sm:flex" : "",
55
57
  ]}
56
58
  data-blume-card
57
- href={href}
59
+ href={href === undefined ? undefined : contentHref(href)}
58
60
  rel={external ? "noreferrer" : undefined}
59
61
  target={external ? "_blank" : undefined}
60
62
  >
@@ -66,7 +68,7 @@ const variantClass = {
66
68
  "w-full object-cover",
67
69
  isHorizontal ? "sm:w-40 sm:shrink-0" : "max-h-48",
68
70
  ]}
69
- src={img}
71
+ src={withBase(img)}
70
72
  />
71
73
  )
72
74
  }
@@ -26,7 +26,16 @@ const canRenderIcon =
26
26
  <span class:list={[markerClass, !canRenderIcon && "before:[content:counter(blume-step)]"]}>
27
27
  {canRenderIcon && <Icon name={icon} size={14} />}
28
28
  </span>
29
- {title && <p class="mb-1.5 font-semibold">{title}</p>}
29
+ {
30
+ // Consumes the `titleSize` variables set by <Steps>; the fallbacks match
31
+ // its "p" default (and the prose-inherited sizing) so a bare <Step>
32
+ // renders identically.
33
+ title && (
34
+ <p class="mb-1.5 font-semibold text-[length:var(--blume-step-title-size,1rem)] leading-[var(--blume-step-title-line-height,1.75rem)]">
35
+ {title}
36
+ </p>
37
+ )
38
+ }
30
39
  <div class="[&>:first-child]:mt-0! [&>:last-child]:mb-0!">
31
40
  <slot />
32
41
  </div>
@@ -51,6 +51,10 @@ const hashEnabled = String(hash) !== "false";
51
51
  "w-full rounded-blume border border-border bg-background px-3 py-2 font-medium text-foreground text-sm";
52
52
  const SYNC_EVENT = "blume-tabs-change";
53
53
 
54
+ // Disambiguates generated trigger/panel ids when the same tab labels appear
55
+ // in several <Tabs> instances on one page.
56
+ let instanceCount = 0;
57
+
54
58
  const panelTitle = (panel: HTMLElement, index: number) =>
55
59
  panel.dataset.title ?? `Tab ${index + 1}`;
56
60
  const panelId = (panel: HTMLElement) => panel.dataset.tabId ?? panel.id;
@@ -119,7 +123,8 @@ const hashEnabled = String(hash) !== "false";
119
123
  if (this.dataset.dropdown === "true") {
120
124
  this.#select = document.createElement("select");
121
125
  this.#select.className = SELECT_CLASS;
122
- this.#select.setAttribute("aria-label", "Select code example");
126
+ // Neutral default: tab sets aren't necessarily code examples.
127
+ this.#select.setAttribute("aria-label", "Select tab");
123
128
  panels.forEach((panel, index) => {
124
129
  const option = document.createElement("option");
125
130
  option.value = String(index);
@@ -131,6 +136,7 @@ const hashEnabled = String(hash) !== "false";
131
136
  });
132
137
  list.appendChild(this.#select);
133
138
  } else {
139
+ const instance = ++instanceCount;
134
140
  panels.forEach((panel, index) => {
135
141
  const trigger = document.createElement("button");
136
142
  trigger.type = "button";
@@ -138,9 +144,15 @@ const hashEnabled = String(hash) !== "false";
138
144
  trigger.className = TRIGGER_CLASS;
139
145
  appendTriggerContent(trigger, panel, index);
140
146
  trigger.tabIndex = index === 0 ? 0 : -1;
141
- if (panel.id) {
142
- trigger.setAttribute("aria-controls", panel.id);
147
+ // Wire tab and panel both ways. An author-supplied panel id is kept;
148
+ // otherwise derive unique ids from the label slug per instance.
149
+ const slug = panelId(panel) || `tab-${index + 1}`;
150
+ if (!panel.id) {
151
+ panel.id = `blume-tabpanel-${slug}-${instance}`;
143
152
  }
153
+ trigger.id = `blume-tab-${slug}-${instance}`;
154
+ trigger.setAttribute("aria-controls", panel.id);
155
+ panel.setAttribute("aria-labelledby", trigger.id);
144
156
  trigger.setAttribute(
145
157
  "aria-selected",
146
158
  index === 0 ? "true" : "false"