blume 0.5.1 → 0.5.3

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 (60) hide show
  1. package/dist/cli/index.js +2996 -2762
  2. package/dist/cli/index.js.map +35 -32
  3. package/dist/types/core/package-json.d.ts +12 -0
  4. package/dist/types/migrate/shared.d.ts +153 -0
  5. package/docs/advanced/migrate.mdx +1 -0
  6. package/docs/configuration/ai.mdx +1 -1
  7. package/docs/configuration/theming.mdx +1 -1
  8. package/docs/content/i18n.mdx +1 -1
  9. package/docs/content/sources.mdx +1 -1
  10. package/package.json +1 -1
  11. package/src/ai/mcp/discovery.ts +3 -1
  12. package/src/ai/mcp/server.ts +3 -1
  13. package/src/astro/component-slots.ts +10 -2
  14. package/src/astro/generate.ts +11 -1
  15. package/src/astro/static-assets.ts +10 -3
  16. package/src/astro/templates.ts +87 -29
  17. package/src/cli/coalesce.ts +43 -0
  18. package/src/cli/commands/dev.ts +31 -17
  19. package/src/cli/commands/init.ts +2 -27
  20. package/src/cli/dev-lock.ts +4 -2
  21. package/src/components/content/ColorItem.astro +6 -3
  22. package/src/components/content/Prompt.astro +7 -3
  23. package/src/components/content/Tabs.astro +13 -2
  24. package/src/components/content/mermaid-element.ts +20 -2
  25. package/src/components/islands/ask-ai.tsx +4 -8
  26. package/src/components/islands/base-path.ts +30 -0
  27. package/src/components/islands/hooks.ts +12 -8
  28. package/src/components/layout/PageActions.astro +17 -11
  29. package/src/components/layout/Search.astro +4 -1
  30. package/src/components/layout/search/types.ts +16 -5
  31. package/src/components/openapi/ParametersTable.astro +1 -1
  32. package/src/components/openapi/SchemaProperty.astro +1 -1
  33. package/src/components/openapi/SchemaTable.astro +3 -3
  34. package/src/components/openapi/helpers.ts +17 -8
  35. package/src/components/openapi/snippets.ts +17 -4
  36. package/src/core/config.ts +15 -6
  37. package/src/core/graph.ts +6 -1
  38. package/src/core/navigation.ts +5 -1
  39. package/src/core/package-json.ts +32 -0
  40. package/src/core/sources/filesystem.ts +19 -1
  41. package/src/core/sources/mdx-remote.ts +20 -4
  42. package/src/core/sources/mintlify.ts +30 -1
  43. package/src/core/sources/normalize.ts +28 -6
  44. package/src/core/sources/watch.ts +44 -0
  45. package/src/markdown/code-title.ts +6 -3
  46. package/src/markdown/package-install.ts +3 -1
  47. package/src/migrate/fumadocs/content.ts +3 -5
  48. package/src/migrate/fumadocs/index.ts +24 -9
  49. package/src/migrate/mintlify/config.ts +2 -6
  50. package/src/migrate/mintlify/index.ts +143 -32
  51. package/src/migrate/mintlify/snippets.ts +17 -8
  52. package/src/migrate/nextra/index.ts +16 -1
  53. package/src/migrate/shared.ts +101 -5
  54. package/src/migrate/starlight/content.ts +3 -6
  55. package/src/og/card.ts +16 -4
  56. package/src/openapi/render-mdx.ts +10 -1
  57. package/src/search/sync/orama-cloud.ts +2 -0
  58. package/src/search/sync/typesense.ts +4 -0
  59. package/src/theme/icons.ts +13 -4
  60. package/src/theme/palette.ts +38 -17
@@ -3,6 +3,7 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
3
3
  import { dirname, relative, resolve } from "pathe";
4
4
 
5
5
  import matter from "../../core/frontmatter.ts";
6
+ import { isInsideRoot, stripImports } from "../shared.ts";
6
7
 
7
8
  const MARKDOWN_SNIPPET_IMPORT =
8
9
  /^import\s+(?<name>[$A-Z_a-z][$\w]*)\s+from\s+["'](?<source>[^"']+\.mdx?)["'];?\s*$/gmu;
@@ -37,11 +38,6 @@ interface SnippetTransformOptions {
37
38
  trail?: string[];
38
39
  }
39
40
 
40
- const isInsideRoot = (root: string, candidate: string): boolean => {
41
- const rel = relative(root, candidate);
42
- return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
43
- };
44
-
45
41
  const escapeRegExp = (value: string): string =>
46
42
  value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
47
43
 
@@ -151,7 +147,9 @@ const interpolateProps = (
151
147
  source.replaceAll(PLACEHOLDER, (value, name: string) => props[name] ?? value);
152
148
 
153
149
  const stripImport = (source: string, importText: string): string =>
154
- source.replace(importText, "").replaceAll(/\n{3,}/gu, "\n\n");
150
+ // Seam-targeted removal: a whole-document `\n{3,}` collapse would rewrite
151
+ // real double blank lines inside the page's code fences.
152
+ stripImports(source, new RegExp(escapeRegExp(importText), "gu"));
155
153
 
156
154
  const replacePlaceholder = (
157
155
  source: string,
@@ -189,9 +187,20 @@ export const rewriteMintlifyMarkdownSnippets = async (
189
187
  );
190
188
  }
191
189
  seen.add(file);
192
- const readFile = options.readFile ?? readFileFromDisk;
190
+ const readFile =
191
+ options.readFile ??
192
+ ((path: string): Promise<string> => readFileFromDisk(path, "utf-8"));
193
193
  try {
194
- const raw = await readFile(file);
194
+ let raw: string;
195
+ try {
196
+ raw = await readFile(file);
197
+ } catch {
198
+ // A dangling import must fail with a message that names both ends —
199
+ // the caller downgrades it to a per-page warning and moves on.
200
+ throw new Error(
201
+ `snippet ${rootRelativePath(options.root, file)} (imported by ${rootRelativePath(options.root, options.filePath)}) does not exist`
202
+ );
203
+ }
195
204
  const content = matter(raw).content.trim();
196
205
  const transformed = await rewriteMintlifyMarkdownSnippets(content, {
197
206
  ...options,
@@ -5,8 +5,9 @@ import { basename, dirname, extname, join, relative } from "pathe";
5
5
  import { glob } from "tinyglobby";
6
6
 
7
7
  import matter from "../../core/frontmatter.ts";
8
+ import { ensureGitignore } from "../../core/gitignore.ts";
8
9
  import type { BlumeConfig, FolderMeta } from "../../core/schema.ts";
9
- import { writeBlumeConfig } from "../shared.ts";
10
+ import { rewriteFrameworkScripts, writeBlumeConfig } from "../shared.ts";
10
11
  import {
11
12
  rewriteNextraCallouts,
12
13
  stripNextraImports,
@@ -353,11 +354,25 @@ export const migrateNextraProject = async (
353
354
  );
354
355
  await writeBlumeConfig(root, buildConfig(plan.tabs));
355
356
 
357
+ // Tear down the Next scaffolding like the Fumadocs migrator does — without
358
+ // this, `npm run dev` still launches Next against the gutted content tree.
359
+ const scriptsRewritten = await rewriteFrameworkScripts(
360
+ root,
361
+ /\bnext\b/u,
362
+ /\bnextra\b/u
363
+ );
364
+ await ensureGitignore(root, [".blume/", "dist/"]);
365
+
356
366
  const warnings = [
357
367
  ...plan.warnings,
358
368
  ...pages.skipped.map((rel) => `Skipped ${rel} (target already exists)`),
359
369
  ...relocateWarnings,
360
370
  ];
371
+ if (scriptsRewritten) {
372
+ warnings.push(
373
+ "Repointed package.json scripts at Blume (dev/build/preview); remove the leftover Next/Nextra dependencies and next.config/theme.config by hand."
374
+ );
375
+ }
361
376
  if (pages.removedKeys.length > 0) {
362
377
  warnings.push(
363
378
  `Dropped unsupported page frontmatter keys: ${pages.removedKeys.join(", ")}.`
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
 
4
- import { isAbsolute, join, relative } from "pathe";
4
+ import { basename, isAbsolute, join, relative } from "pathe";
5
5
 
6
+ import { blumePackageJson, toPackageName } from "../core/package-json.ts";
6
7
  import type { BlumeConfig } from "../core/schema.ts";
7
8
  import { pageMetaSchema } from "../core/schema.ts";
8
9
 
@@ -102,6 +103,49 @@ export const rewriteFrameworkScripts = async (
102
103
  export const leftoverFiles = (root: string, candidates: string[]): string[] =>
103
104
  candidates.filter((candidate) => existsSync(join(root, candidate)));
104
105
 
106
+ /**
107
+ * Scaffold a minimal, runnable `package.json` when the migrated project has
108
+ * none. Config-only sources (e.g. a Mintlify `docs.json`) ship no npm manifest,
109
+ * so a fresh migration has nothing to run `blume dev` with; this writes a stub
110
+ * with `blume` as a dependency and `dev`/`build`/`doctor` scripts, making
111
+ * `npm install && npm run dev` work immediately. A pre-existing `package.json`
112
+ * is left untouched — {@link rewriteFrameworkScripts} repoints those instead.
113
+ * Returns true when a file was created.
114
+ */
115
+ export const ensurePackageJson = async (root: string): Promise<boolean> => {
116
+ const pkgPath = join(root, "package.json");
117
+ if (existsSync(pkgPath)) {
118
+ return false;
119
+ }
120
+ await writeFile(
121
+ pkgPath,
122
+ blumePackageJson(toPackageName(basename(root))),
123
+ "utf-8"
124
+ );
125
+ return true;
126
+ };
127
+
128
+ // A marker no markdown document contains, planted where an import was removed
129
+ // so the blank-gap collapse can target the seams alone.
130
+ const IMPORT_SEAM = "\u0000blume-import-seam\u0000";
131
+
132
+ /**
133
+ * Remove every match of an import pattern, collapsing the blank gap each
134
+ * removal leaves behind — without touching blank runs elsewhere in the
135
+ * document (double blank lines inside code fences are real content that a
136
+ * whole-document `\n{3,}` collapse used to corrupt).
137
+ */
138
+ export const stripImports = (source: string, pattern: RegExp): string => {
139
+ const marked = source.replaceAll(pattern, IMPORT_SEAM);
140
+ if (marked === source) {
141
+ return source;
142
+ }
143
+ return marked
144
+ .replaceAll(new RegExp(String.raw`\n*(?:${IMPORT_SEAM})+\n*`, "gu"), "\n\n")
145
+ .replace(/^\n+/u, "")
146
+ .replace(/\n+$/u, "\n");
147
+ };
148
+
105
149
  // ---------------------------------------------------------------------------
106
150
  // Callout components -> Blume `:::` directives
107
151
  // ---------------------------------------------------------------------------
@@ -138,14 +182,30 @@ const dedent = (value: string): string => {
138
182
  return lines.map((line) => line.slice(common)).join("\n");
139
183
  };
140
184
 
185
+ /**
186
+ * A colon fence one longer than any directive fence in the body — nested
187
+ * container directives require the outer fence to be longer than the inner
188
+ * ones, or the inner `:::` closes the outer block.
189
+ */
190
+ const fenceOver = (body: string): string => {
191
+ let max = 2;
192
+ for (const match of body.matchAll(/^ {0,3}(?<colons>:{3,})/gmu)) {
193
+ max = Math.max(max, match.groups?.colons?.length ?? 0);
194
+ }
195
+ return ":".repeat(Math.max(3, max + 1));
196
+ };
197
+
141
198
  const directiveBlock = (
142
199
  directive: string,
143
200
  title: string | undefined,
144
201
  inner: string
145
202
  ): string => {
146
- const head = title ? `:::${directive}[${title}]` : `:::${directive}`;
147
203
  const body = dedent(inner);
148
- return `${head}\n${body}\n:::`;
204
+ const fence = fenceOver(body);
205
+ const head = title
206
+ ? `${fence}${directive}[${title}]`
207
+ : `${fence}${directive}`;
208
+ return `${head}\n${body}\n${fence}`;
149
209
  };
150
210
 
151
211
  /**
@@ -177,6 +237,40 @@ export const findOpenTagEnd = (source: string, from: number): number => {
177
237
  return -1;
178
238
  };
179
239
 
240
+ /**
241
+ * Find the close tag matching an open tag, honoring same-tag nesting
242
+ * (`<Note>a<Note>b</Note>c</Note>` must close at the *outer* `</Note>`).
243
+ * Returns the index of the matching `</Tag>`, or -1 when unterminated.
244
+ */
245
+ const findMatchingClose = (
246
+ source: string,
247
+ tag: string,
248
+ from: number
249
+ ): number => {
250
+ const scanner = new RegExp(`<(?<closing>/)?${tag}(?=[\\s/>])`, "gu");
251
+ scanner.lastIndex = from;
252
+ let depth = 0;
253
+ for (let match = scanner.exec(source); match; match = scanner.exec(source)) {
254
+ if (match.groups?.closing) {
255
+ if (depth === 0) {
256
+ return match.index;
257
+ }
258
+ depth -= 1;
259
+ continue;
260
+ }
261
+ // A nested open tag: skip its attributes; self-closing ones don't nest.
262
+ const end = findOpenTagEnd(source, match.index + tag.length + 1);
263
+ if (end === -1) {
264
+ return -1;
265
+ }
266
+ if (source[end - 1] !== "/") {
267
+ depth += 1;
268
+ }
269
+ scanner.lastIndex = end + 1;
270
+ }
271
+ return -1;
272
+ };
273
+
180
274
  const directiveFor = (
181
275
  tag: string,
182
276
  attrs: string,
@@ -233,7 +327,7 @@ export const rewriteCallouts = (
233
327
  const selfClosing = attrs.trimEnd().endsWith("/");
234
328
  const closeIndex = selfClosing
235
329
  ? openEnd
236
- : source.indexOf(closeTag, openEnd + 1);
330
+ : findMatchingClose(source, tag, openEnd + 1);
237
331
 
238
332
  if (!directive || (!selfClosing && closeIndex === -1)) {
239
333
  output += source.slice(cursor, openEnd + 1);
@@ -249,10 +343,12 @@ export const rewriteCallouts = (
249
343
  : `:::${directive}\n:::`;
250
344
  cursor = openEnd + 1;
251
345
  } else {
346
+ // Recurse so nested callouts (of any tag) convert too — they'd
347
+ // otherwise survive as components Blume doesn't ship.
252
348
  output += directiveBlock(
253
349
  directive,
254
350
  title,
255
- source.slice(openEnd + 1, closeIndex)
351
+ rewriteCallouts(source.slice(openEnd + 1, closeIndex), options)
256
352
  );
257
353
  cursor = closeIndex + closeTag.length;
258
354
  }
@@ -1,4 +1,4 @@
1
- import { renameTag, rewriteCallouts } from "../shared.ts";
1
+ import { renameTag, rewriteCallouts, stripImports } from "../shared.ts";
2
2
 
3
3
  /**
4
4
  * Source-to-source rewrites that turn Starlight-only MDX into idiomatic Blume
@@ -14,11 +14,8 @@ const STARLIGHT_IMPORT =
14
14
  * Blume injects its components globally, so these imports would fail to resolve
15
15
  * once the Starlight packages are gone.
16
16
  */
17
- export const stripStarlightImports = (source: string): string => {
18
- const stripped = source.replace(STARLIGHT_IMPORT, "");
19
- // Collapse the blank gap a removed import block leaves behind.
20
- return stripped === source ? source : stripped.replaceAll(/\n{3,}/gu, "\n\n");
21
- };
17
+ export const stripStarlightImports = (source: string): string =>
18
+ stripImports(source, STARLIGHT_IMPORT);
22
19
 
23
20
  /** Starlight `<Aside type="X">` values mapped to Blume directive names. */
24
21
  const ASIDE_TYPE_DIRECTIVES: Record<string, string> = {
package/src/og/card.ts CHANGED
@@ -19,10 +19,19 @@ const ACCENT_HEX: Record<string, string> = {
19
19
  teal: "#14b8a6",
20
20
  };
21
21
 
22
+ const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
23
+
22
24
  // OG rendering uses hex (Takumi's color parser does not accept oklch); named
23
- // presets map to hex, raw hex passes through, anything else falls back.
24
- const resolveAccent = (accent: string): string =>
25
- ACCENT_HEX[accent] ?? (accent.startsWith("#") ? accent : "#3b82f6");
25
+ // presets map to hex, well-formed hex passes through, anything else falls
26
+ // back a malformed hex (`#12345` typo) would throw inside Takumi and fail
27
+ // the build at OG prerender with an opaque native error. `hasOwn` keeps a
28
+ // preset name like "constructor" from resolving up the prototype chain.
29
+ const resolveAccent = (accent: string): string => {
30
+ if (Object.hasOwn(ACCENT_HEX, accent)) {
31
+ return ACCENT_HEX[accent] as string;
32
+ }
33
+ return HEX_COLOR.test(accent) ? accent : "#3b82f6";
34
+ };
26
35
 
27
36
  export interface OgCardOptions {
28
37
  /** Large headline — the page title. */
@@ -75,7 +84,10 @@ export const truncate = (value: string, max: number): string => {
75
84
  // stays within the lockup.
76
85
  const MARK_HEIGHT = 32;
77
86
  const MARK_MAX_WIDTH = 100;
78
- const VIEW_BOX = /viewBox="0 0 (?<w>[\d.]+) (?<h>[\d.]+)"/u;
87
+ // Accept either quote style and a non-zero min-x/min-y; only width/height
88
+ // matter for the aspect ratio. A miss falls back to a square mark.
89
+ const VIEW_BOX =
90
+ /viewBox=(?<q>["'])[\d.-]+[\s,]+[\d.-]+[\s,]+(?<w>[\d.]+)[\s,]+(?<h>[\d.]+)\k<q>/u;
79
91
 
80
92
  // Render the configured logo as the brand mark. A `currentColor` logo carries
81
93
  // no intrinsic color, so it is painted in the foreground to read on the light
@@ -21,8 +21,17 @@ const ENTITIES: Record<string, string> = {
21
21
  "{": "&#123;",
22
22
  "}": "&#125;",
23
23
  };
24
+ // MDX also parses lines starting with `import`/`export` as ESM ("import the
25
+ // SDK…" is common spec prose). Entity-escape the keyword's first letter so the
26
+ // construct can't match; it still renders as the literal word.
27
+ const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
24
28
  const mdxSafe = (text: string): string =>
25
- text.replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char);
29
+ text
30
+ .replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char)
31
+ .replace(
32
+ MDX_ESM_KEYWORD,
33
+ (keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
34
+ );
26
35
 
27
36
  /** Frontmatter + body for one operation or overview page. */
28
37
  export interface RenderedPage {
@@ -31,6 +31,8 @@ export const syncOramaCloud = async (
31
31
  content: record.content,
32
32
  description: record.description,
33
33
  id: record._id,
34
+ // Carried so an i18n site can filter hosted results per language.
35
+ locale: record.locale,
34
36
  tag: record.tag,
35
37
  title: record.title,
36
38
  url: record.url,
@@ -54,6 +54,9 @@ export const syncTypesense = async (
54
54
  { name: "content", type: "string" },
55
55
  { name: "url", type: "string" },
56
56
  { facet: true, name: "tag", optional: true, type: "string" },
57
+ // Carried as a facet so an i18n site can filter hosted results per
58
+ // language (the SearchRecord contract).
59
+ { facet: true, name: "locale", optional: true, type: "string" },
57
60
  ],
58
61
  name: config.collection,
59
62
  });
@@ -62,6 +65,7 @@ export const syncTypesense = async (
62
65
  content: record.content,
63
66
  description: record.description,
64
67
  id: record._id,
68
+ locale: record.locale,
65
69
  tag: record.tag,
66
70
  title: record.title,
67
71
  url: record.url,
@@ -75,6 +75,15 @@ const PREFIX_SETS: Record<string, string> = {
75
75
  ti: "tabler",
76
76
  };
77
77
 
78
+ /**
79
+ * Own-property map lookup. Icon names and library/iconType hints come from
80
+ * content and config, so a value like `constructor:x` would otherwise resolve
81
+ * an Object.prototype member (a function) and crash resolution deep in the
82
+ * build with no pointer to the offending page.
83
+ */
84
+ const ownEntry = <T>(map: Record<string, T>, key: string): T | undefined =>
85
+ Object.hasOwn(map, key) ? map[key] : undefined;
86
+
78
87
  export interface ResolvedIcon {
79
88
  /** Inner SVG markup (self-styled: carries its own fill/stroke). */
80
89
  body: string;
@@ -100,13 +109,13 @@ const normalize = (name: string): string =>
100
109
  /** Which set a bare name resolves against, given library/iconType hints. */
101
110
  const setFor = (options: ResolveIconOptions): string => {
102
111
  if (options.iconType) {
103
- const set = ICON_TYPE_SETS[normalize(options.iconType)];
112
+ const set = ownEntry(ICON_TYPE_SETS, normalize(options.iconType));
104
113
  if (set) {
105
114
  return set;
106
115
  }
107
116
  }
108
117
  if (options.library) {
109
- const set = LIBRARY_SETS[normalize(options.library)];
118
+ const set = ownEntry(LIBRARY_SETS, normalize(options.library));
110
119
  if (set) {
111
120
  return set;
112
121
  }
@@ -115,7 +124,7 @@ const setFor = (options: ResolveIconOptions): string => {
115
124
  };
116
125
 
117
126
  const fromSet = (setName: string, iconName: string): ResolvedIcon | null => {
118
- const set = SETS[setName];
127
+ const set = ownEntry(SETS, setName);
119
128
  const data = set && getIconData(set, iconName);
120
129
  if (!data) {
121
130
  return null;
@@ -146,7 +155,7 @@ export const resolveIcon = (
146
155
  const normalized = normalize(name);
147
156
  const colon = normalized.indexOf(":");
148
157
  if (colon > 0) {
149
- const setName = PREFIX_SETS[normalized.slice(0, colon)];
158
+ const setName = ownEntry(PREFIX_SETS, normalized.slice(0, colon));
150
159
  if (setName) {
151
160
  return resolveInSet(setName, normalized.slice(colon + 1));
152
161
  }
@@ -22,6 +22,17 @@ const CSS_COLOR = /^[\w\s#%.,()/+-]+$/u;
22
22
  const safeColor = (value: string, fallback: string): string =>
23
23
  CSS_COLOR.test(value.trim()) ? value.trim() : fallback;
24
24
 
25
+ /**
26
+ * Resolve a named preset or fall back to {@link safeColor}. `hasOwn` keeps a
27
+ * value like "constructor" from resolving an Object.prototype member — which
28
+ * would stringify a function into the generated CSS, breaking the rule (the
29
+ * exact breakout safeColor exists to prevent).
30
+ */
31
+ const presetOrColor = (value: string): string =>
32
+ Object.hasOwn(ACCENTS, value)
33
+ ? (ACCENTS[value] as string)
34
+ : safeColor(value, FALLBACK_ACCENT);
35
+
25
36
  /** Like {@link safeColor} but drops an unsafe/absent value to `null`. */
26
37
  const safeColorOrNull = (value: string | undefined): string | null =>
27
38
  value && CSS_COLOR.test(value.trim()) ? value.trim() : null;
@@ -94,10 +105,24 @@ const themeRootCss = (
94
105
 
95
106
  const themeDarkCss = (
96
107
  theme: ResolvedConfig["theme"],
97
- accentDark: string | null
108
+ options: {
109
+ accent: string;
110
+ action: string | null;
111
+ backgroundDecoration: string;
112
+ }
98
113
  ): string => {
114
+ // Mode-shared tokens (accent, action, decoration) must be re-declared here:
115
+ // the base stylesheet's own `:root[data-theme="dark"]` block outranks the
116
+ // `:root` config tokens on specificity, so without this block dark mode
117
+ // would silently keep its neutral defaults and ignore the config.
99
118
  const tokens = [
100
- ...cssToken("--blume-accent", accentDark),
119
+ ` --blume-accent: ${options.accent};`,
120
+ " --blume-accent-foreground: oklch(1 0 0);",
121
+ ...cssToken("--blume-action", options.action),
122
+ ...cssToken(
123
+ "--blume-action-foreground",
124
+ options.action ? "oklch(1 0 0)" : null
125
+ ),
101
126
  ...cssToken("--blume-background", safeColorOrNull(theme.backgroundDark)),
102
127
  ...cssToken(
103
128
  "--blume-background-image",
@@ -105,10 +130,8 @@ const themeDarkCss = (
105
130
  ? backgroundImageCss(theme.backgroundImageDark)
106
131
  : null
107
132
  ),
108
- ];
109
- if (tokens.length === 0) {
110
- return "";
111
- }
133
+ options.backgroundDecoration.trimEnd(),
134
+ ].filter(Boolean);
112
135
  return `:root[data-theme="dark"] {
113
136
  ${tokens.join("\n")}
114
137
  }
@@ -121,7 +144,7 @@ ${tokens.join("\n")}
121
144
  * arbitrary colors without a config change.
122
145
  */
123
146
  export const resolveAccent = (theme: ResolvedConfig["theme"]): string =>
124
- ACCENTS[theme.accent] ?? safeColor(theme.accent, FALLBACK_ACCENT);
147
+ presetOrColor(theme.accent);
125
148
 
126
149
  /** Resolve the configured radius preset to a CSS length. */
127
150
  export const resolveRadius = (theme: ResolvedConfig["theme"]): string =>
@@ -133,15 +156,9 @@ export const resolveRadius = (theme: ResolvedConfig["theme"]): string =>
133
156
  * arbitrary colors without a config change.
134
157
  */
135
158
  export const buildThemeCss = (theme: ResolvedConfig["theme"]): string => {
136
- const accent =
137
- ACCENTS[theme.accent] ?? safeColor(theme.accent, FALLBACK_ACCENT);
138
- const accentDark = theme.accentDark
139
- ? (ACCENTS[theme.accentDark] ??
140
- safeColor(theme.accentDark, FALLBACK_ACCENT))
141
- : null;
142
- const action = theme.action
143
- ? (ACCENTS[theme.action] ?? safeColor(theme.action, FALLBACK_ACCENT))
144
- : null;
159
+ const accent = presetOrColor(theme.accent);
160
+ const accentDark = theme.accentDark ? presetOrColor(theme.accentDark) : null;
161
+ const action = theme.action ? presetOrColor(theme.action) : null;
145
162
  const backgroundDecoration = backgroundDecorationCss(
146
163
  theme.backgroundDecoration
147
164
  );
@@ -152,7 +169,11 @@ export const buildThemeCss = (theme: ResolvedConfig["theme"]): string => {
152
169
  backgroundDecoration,
153
170
  radius,
154
171
  });
155
- const dark = themeDarkCss(theme, accentDark);
172
+ const dark = themeDarkCss(theme, {
173
+ accent: accentDark ?? accent,
174
+ action,
175
+ backgroundDecoration,
176
+ });
156
177
 
157
178
  return `/* Generated by Blume from theme config. */
158
179
  :root {