blume 0.5.4 → 0.6.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 (57) hide show
  1. package/dist/cli/index.js +759 -406
  2. package/dist/cli/index.js.map +27 -25
  3. package/dist/types/core/config-input.d.ts +759 -0
  4. package/dist/types/core/config.d.ts +126 -3
  5. package/dist/types/core/data.d.ts +4 -0
  6. package/dist/types/core/i18n-ui.d.ts +50 -0
  7. package/dist/types/core/schema.d.ts +334 -62
  8. package/dist/types/core/types.d.ts +8 -0
  9. package/dist/types/index.d.ts +2 -1
  10. package/docs/advanced/changelog.mdx +10 -2
  11. package/docs/configuration/ai.mdx +56 -0
  12. package/docs/configuration/index.mdx +0 -2
  13. package/docs/configuration/seo.mdx +59 -1
  14. package/docs/configuration/theming.mdx +14 -9
  15. package/docs/content/meta.mdx +3 -17
  16. package/docs/content/navigation.mdx +41 -4
  17. package/docs/content/syntax.mdx +4 -8
  18. package/package.json +3 -1
  19. package/src/ai/agent-readability.ts +97 -0
  20. package/src/ai/ask-context.ts +131 -8
  21. package/src/ai/ask-data.ts +4 -1
  22. package/src/astro/generate.ts +40 -11
  23. package/src/astro/templates.ts +90 -10
  24. package/src/cli/commands/build.ts +41 -1
  25. package/src/cli/commands/dev.ts +31 -14
  26. package/src/cli/dev-lock.ts +94 -21
  27. package/src/components/content/GithubInfo.astro +11 -10
  28. package/src/components/content/TypeTable.astro +8 -3
  29. package/src/components/content/Update.astro +12 -2
  30. package/src/components/content/changelog-element.ts +62 -0
  31. package/src/components/islands/AskAI.astro +66 -2
  32. package/src/components/islands/ask-ai.tsx +289 -53
  33. package/src/components/layout/Header.astro +1 -1
  34. package/src/components/layout/NavTree.astro +1 -1
  35. package/src/components/layout/PageActions.astro +73 -30
  36. package/src/components/layout/RootLayout.astro +79 -10
  37. package/src/core/config-input.ts +933 -0
  38. package/src/core/config.ts +126 -3
  39. package/src/core/data.ts +4 -0
  40. package/src/core/graph.ts +7 -2
  41. package/src/core/i18n-ui.ts +5 -0
  42. package/src/core/nav-diagnostics.ts +7 -0
  43. package/src/core/navigation.ts +38 -12
  44. package/src/core/schema.ts +130 -22
  45. package/src/core/sources/filesystem.ts +5 -1
  46. package/src/core/sources/watch.ts +43 -12
  47. package/src/core/types.ts +9 -0
  48. package/src/deploy/adapter-output.ts +82 -0
  49. package/src/deploy/robots.ts +37 -4
  50. package/src/index.ts +1 -1
  51. package/src/markdown/index.ts +28 -30
  52. package/src/markdown/math.ts +3 -2
  53. package/src/openapi/scalar.ts +1 -1
  54. package/src/registry/eject.ts +21 -14
  55. package/src/search/documents.ts +9 -2
  56. package/src/theme/entry.ts +7 -3
  57. package/src/theme/palette.ts +21 -14
@@ -1,20 +1,51 @@
1
1
  import type { WatchListener } from "node:fs";
2
2
 
3
3
  /**
4
- * Directory segments a recursive dev watcher must never react to. When a
5
- * source's content root is the project root — a `.`-rooted layout, or an
6
- * all-staged project (openapi/notion/github-releases/…) with no filesystem
7
- * source — a naive recursive `fs.watch` also sees Blume's own `.blume/`
8
- * output, which the dev server rewrites on every render (e.g.
9
- * `.blume/.astro/data-store.json`). Left unfiltered, each such write re-triggers
10
- * a rescan + runtime regeneration whose writes land back under `.blume/` and
11
- * fire the watcher again: a self-sustaining loop that stalls page renders and
12
- * floods the console (and, mid-render, corrupts Astro's dev module graph so
13
- * `astro:server-app.js` fails to load). `.git`/`node_modules` are here for the
14
- * same reason — churn that is never page content. `fs.watch` has no ignore
4
+ * Directory segments that are never authored content: VCS, dependency trees,
5
+ * Blume's own generated project and build output, and framework/deploy caches.
6
+ * Both the content scan and the dev watcher skip these unconditionally, on top
7
+ * of the user's `content.exclude`.
8
+ *
9
+ * The scan needs them because a broadly-scoped `content.root` — `"."` or an app
10
+ * dir that also holds `node_modules`/`dist`, the common shape when migrating a
11
+ * docs app that lives at the repo or app root — would otherwise glob thousands
12
+ * of stray markdown files out of dependencies and build artifacts. `content.root`
13
+ * defaults to `docs/` where this rarely bites, but any wider root hits it.
14
+ *
15
+ * The watcher needs them because a recursive `fs.watch` rooted at the project
16
+ * also sees Blume's own `.blume/` output, which the dev server rewrites on every
17
+ * render (e.g. `.blume/.astro/data-store.json`). Left unfiltered, each such write
18
+ * re-triggers a rescan + runtime regeneration whose writes land back under
19
+ * `.blume/` and fire the watcher again: a self-sustaining loop that stalls page
20
+ * renders and floods the console (and, mid-render, corrupts Astro's dev module
21
+ * graph so `astro:server-app.js` fails to load). `fs.watch` has no ignore
15
22
  * option, so we filter by the changed path in the callback.
16
23
  */
17
- export const BLUME_WATCH_IGNORE_DIRS = [".blume", ".git", "node_modules"];
24
+ export const BLUME_IGNORE_DIRS = [
25
+ ".blume",
26
+ ".cache",
27
+ ".git",
28
+ ".next",
29
+ ".turbo",
30
+ ".vercel",
31
+ "dist",
32
+ "node_modules",
33
+ ];
34
+
35
+ /**
36
+ * Baseline scan-ignore globs applied to every filesystem source, unioned with
37
+ * the user's `content.exclude`. Kept in sync with the watcher via the shared
38
+ * {@link BLUME_IGNORE_DIRS} so `load()` and `watch()` never disagree on what is
39
+ * content. `**\/<dir>/**` matches the directory at the content root or nested.
40
+ */
41
+ export const baselineScanIgnore = (): string[] =>
42
+ BLUME_IGNORE_DIRS.map((dir) => `**/${dir}/**`);
43
+
44
+ /**
45
+ * Alias retained for the dev watcher's call site and its tests; the watcher and
46
+ * the scan share the same never-content directory set.
47
+ */
48
+ export const BLUME_WATCH_IGNORE_DIRS = BLUME_IGNORE_DIRS;
18
49
 
19
50
  /** Extract single-segment ignore dirs (`foo`) from `foo/**`-style excludes. */
20
51
  export const excludeDirSegments = (patterns: readonly string[]): string[] =>
package/src/core/types.ts CHANGED
@@ -180,11 +180,20 @@ export interface NavSelector {
180
180
  items: NavSelectorItem[];
181
181
  }
182
182
 
183
+ /** A pinned link rendered above the sidebar sections (external or internal). */
184
+ export interface FeaturedLink {
185
+ label: string;
186
+ href: string;
187
+ icon?: string;
188
+ }
189
+
183
190
  /** The complete navigation model derived from the content graph. */
184
191
  export interface Navigation {
185
192
  tabs: NavTab[];
186
193
  selectors: NavSelector[];
187
194
  sidebar: NavNode[];
195
+ /** Pinned links shown above the sidebar sections, unscoped by tab. */
196
+ featured: FeaturedLink[];
188
197
  /** Repo URL for the header link, or null when hidden (`navigation.repo`). */
189
198
  repoUrl?: string | null;
190
199
  }
@@ -0,0 +1,82 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, mkdir, rm } from "node:fs/promises";
3
+
4
+ import { dirname, join } from "pathe";
5
+
6
+ import type { ResolvedConfig } from "../core/schema.ts";
7
+ import type { ProjectContext } from "../core/types.ts";
8
+
9
+ type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
10
+
11
+ /**
12
+ * Server adapters whose deploy bundle lands *outside* Astro's `outDir`, at a
13
+ * path relative to the Astro project root. Blume points the Astro root at the
14
+ * hidden `<root>/.blume` runtime, so these adapters write their bundle to
15
+ * `<root>/.blume/<path>` — where the deploy platform never looks. Each value is
16
+ * the sub-path to surface up to the real project root.
17
+ *
18
+ * `vercel` writes a Build Output API v3 tree at `.vercel/output`; only that
19
+ * subtree is moved, so a `vercel pull`-ed `.vercel/project.json` sitting at the
20
+ * project root survives the relocation. `netlify` owns its whole `.netlify`
21
+ * dir. `node` and `cloudflare` emit into `dist/` (already at the project root),
22
+ * so they are absent here and need no relocation.
23
+ */
24
+ export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
25
+ netlify: ".netlify",
26
+ vercel: ".vercel/output",
27
+ };
28
+
29
+ /**
30
+ * Directory whose contents the deploy platform serves as static files. Build
31
+ * artifacts (robots.txt, sitemap.xml, llms.txt, …) must be written here to be
32
+ * served. For a Vercel server build that is the adapter's
33
+ * `.vercel/output/static`; every other build serves `dist/`.
34
+ */
35
+ export const deployStaticDir = (
36
+ config: ResolvedConfig,
37
+ context: ProjectContext
38
+ ): string => {
39
+ const { adapter, output } = config.deployment;
40
+ if (output === "server" && adapter === "vercel") {
41
+ return join(context.root, ".vercel", "output", "static");
42
+ }
43
+ return context.distDir ?? join(context.root, "dist");
44
+ };
45
+
46
+ /** Outcome of {@link surfaceAdapterOutput}, for logging and `.gitignore`. */
47
+ export type SurfaceResult =
48
+ | { moved: false }
49
+ | { from: string; ignore: string; moved: true; to: string };
50
+
51
+ /**
52
+ * Move a server adapter's deploy bundle out of the hidden `.blume` runtime and
53
+ * up to the project root, where the deploy platform (and `vercel deploy
54
+ * --prebuilt`) expects it. A no-op for static builds, for adapters that emit
55
+ * into `dist/`, and when the expected output is absent.
56
+ */
57
+ export const surfaceAdapterOutput = async (
58
+ config: ResolvedConfig,
59
+ context: ProjectContext
60
+ ): Promise<SurfaceResult> => {
61
+ const { adapter, output } = config.deployment;
62
+ if (output !== "server" || !adapter) {
63
+ return { moved: false };
64
+ }
65
+ const rel = ADAPTER_OUTPUT_PATHS[adapter];
66
+ if (!rel) {
67
+ return { moved: false };
68
+ }
69
+ const from = join(context.outDir, rel);
70
+ const to = join(context.root, rel);
71
+ if (!existsSync(from)) {
72
+ return { moved: false };
73
+ }
74
+ await mkdir(dirname(to), { recursive: true });
75
+ await rm(to, { force: true, recursive: true });
76
+ await cp(from, to, { recursive: true });
77
+ await rm(from, { force: true, recursive: true });
78
+ // The `.gitignore` entry is the surfaced top-level dir (`.vercel`/`.netlify`),
79
+ // never the moved sub-path — Vercel's own `.vercel/project.json` lives there
80
+ // too and must also be ignored.
81
+ return { from, ignore: `${rel.split("/")[0]}/`, moved: true, to };
82
+ };
@@ -1,9 +1,36 @@
1
1
  import type { BlumeProject } from "../core/project-graph.ts";
2
+ import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
2
3
 
3
4
  /**
4
- * Build a robots.txt that allows all crawlers and points to the sitemap when
5
- * one is available (a `site` is set and the sitemap is enabled). Returns null
6
- * when robots generation is disabled.
5
+ * Ordered mapping from config field to its `Content-Signal` token. The order
6
+ * fixes the emitted sequence (`search`, then `ai-input`, then `ai-train`).
7
+ */
8
+ const SIGNAL_TOKENS: [keyof ContentSignalPolicy, string][] = [
9
+ ["search", "search"],
10
+ ["aiInput", "ai-input"],
11
+ ["aiTrain", "ai-train"],
12
+ ];
13
+
14
+ /**
15
+ * The `Content-Signal:` line declaring how crawlers may reuse the site, or null
16
+ * when the declaration is disabled (`contentSignals: false`). Otherwise every
17
+ * signal is emitted with its resolved yes/no value.
18
+ */
19
+ const contentSignalLine = (signals: ContentSignals): string | null => {
20
+ if (!signals) {
21
+ return null;
22
+ }
23
+ const tokens = SIGNAL_TOKENS.map(
24
+ ([key, token]) => `${token}=${signals[key] ? "yes" : "no"}`
25
+ );
26
+ return `Content-Signal: ${tokens.join(", ")}`;
27
+ };
28
+
29
+ /**
30
+ * Build a robots.txt that allows all crawlers, declares any configured
31
+ * `Content-Signal` usage preferences, and points to the sitemap when one is
32
+ * available (a `site` is set and the sitemap is enabled). Returns null when
33
+ * robots generation is disabled.
7
34
  */
8
35
  export const buildRobots = (project: BlumeProject): string | null => {
9
36
  const { config } = project;
@@ -11,7 +38,13 @@ export const buildRobots = (project: BlumeProject): string | null => {
11
38
  return null;
12
39
  }
13
40
 
14
- const lines = ["User-agent: *", "Allow: /"];
41
+ const lines = ["User-agent: *"];
42
+ const signal = contentSignalLine(config.seo.contentSignals);
43
+ if (signal) {
44
+ lines.push(signal);
45
+ }
46
+ lines.push("Allow: /");
47
+
15
48
  const { site } = config.deployment;
16
49
  if (site && config.seo.sitemap) {
17
50
  lines.push("", `Sitemap: ${site.replace(/\/$/u, "")}/sitemap.xml`);
package/src/index.ts CHANGED
@@ -22,8 +22,8 @@ export type {
22
22
  FolderMetaFactory,
23
23
  } from "./core/define-meta.ts";
24
24
  export type { UIStrings } from "./core/i18n-ui.ts";
25
+ export type { BlumeConfig } from "./core/config-input.ts";
25
26
  export type {
26
- BlumeConfig,
27
27
  FolderMeta,
28
28
  HydrationMode,
29
29
  ResolvedConfig,
@@ -46,15 +46,16 @@ type HastPlugin = NonNullable<
46
46
 
47
47
  /**
48
48
  * Hast plugins enabled by config. Inline `` `code`{:lang} `` highlighting is
49
- * opt-in; self-linking heading anchors (`<h2>`–`<h6>` wrapped in an `<a>` to
50
- * their own id) are on unless `markdown.headingAnchors` is `false`. Inline code
51
- * runs first so the anchor wrap re-refs already-highlighted code.
49
+ * always on: it only fires on an explicit trailing `{:lang}` marker, so plain
50
+ * inline code is untouched and there's nothing to opt out of. Self-linking
51
+ * heading anchors (`<h2>`–`<h6>` wrapped in an `<a>` to their own id) are on
52
+ * unless `markdown.headingAnchors` is `false`. Inline code runs first so the
53
+ * anchor wrap re-refs already-highlighted code.
52
54
  */
53
55
  const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
54
- const plugins: HastPlugin[] = [];
55
- if (options.inline) {
56
- plugins.push(inlineCodeHighlightPlugin() as unknown as HastPlugin);
57
- }
56
+ const plugins: HastPlugin[] = [
57
+ inlineCodeHighlightPlugin() as unknown as HastPlugin,
58
+ ];
58
59
  if (options.headingAnchors !== false) {
59
60
  plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
60
61
  }
@@ -204,8 +205,6 @@ export interface BlumeMarkdownOptions {
204
205
  * On unless explicitly `false`.
205
206
  */
206
207
  headingAnchors?: boolean;
207
- /** Highlight inline `` `code`{:lang} `` snippets (`markdown.code.inline`). */
208
- inline?: boolean;
209
208
  }
210
209
 
211
210
  /** Sätteri processor for plain `.md`, with Blume's curated feature set. */
@@ -215,38 +214,37 @@ export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
215
214
  hastPlugins: blumeHastPlugins(options),
216
215
  });
217
216
 
218
- export interface BlumeMdxOptions extends BlumeMarkdownOptions {
219
- /** Enable KaTeX math parsing and rendering. */
220
- math?: boolean;
221
- }
217
+ export type BlumeMdxOptions = BlumeMarkdownOptions;
222
218
 
223
219
  /**
224
220
  * Sätteri MDX processor: Blume's feature set plus the MDAST plugins that target
225
221
  * components — `package-install` → package-manager tabs, `:::note` →
226
- * `<Callout>`, and ` ```mermaid ` → a `<blume-mermaid>` element. Used as the
227
- * `processor` for `@astrojs/mdx` so these apply to
228
- * `.mdx` only (plain `.md` uses {@link blumeMarkdownProcessor}). Math is opt-in
229
- * via config since `$` is common in prose and code.
222
+ * `<Callout>`, ` ```mermaid ` → a `<blume-mermaid>` element, and block math
223
+ * (`$$…$$`) → the `<Math>` component. Used as the `processor` for
224
+ * `@astrojs/mdx` so these apply to `.mdx` only (plain `.md` uses
225
+ * {@link blumeMarkdownProcessor}).
226
+ *
227
+ * Math is always on but block-only: `singleDollarTextMath: false` keeps a bare
228
+ * `$` (currency, shell, code) as literal text and only parses `$$…$$`. The
229
+ * generated runtime imports the `<Math>` component (and KaTeX's stylesheet) only
230
+ * when content actually uses `$$`, so a math-free site ships no KaTeX CSS.
230
231
  *
231
232
  * The plugins are modeled with minimal structural types; bridge them to
232
233
  * Satteri's full `MdastPlugin` type at this single boundary.
233
234
  */
234
- export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) => {
235
- const plugins: unknown[] = [
236
- packageInstallPlugin(),
237
- directiveToCalloutPlugin(),
238
- mermaidPlugin(),
239
- ];
240
- if (options.math) {
241
- plugins.push(mathPlugin());
242
- }
243
- return satteri({
235
+ export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
236
+ satteri({
244
237
  features: {
245
238
  ...FEATURES,
246
239
  directive: true,
247
- ...(options.math ? { math: true } : {}),
240
+ // Block-only: `$$…$$` parses, a bare `$` stays literal text.
241
+ math: { singleDollarTextMath: false },
248
242
  },
249
243
  hastPlugins: blumeHastPlugins(options),
250
- mdastPlugins: plugins as unknown as MdastPlugin[],
244
+ mdastPlugins: [
245
+ packageInstallPlugin(),
246
+ directiveToCalloutPlugin(),
247
+ mermaidPlugin(),
248
+ mathPlugin(),
249
+ ] as unknown as MdastPlugin[],
251
250
  });
252
- };
@@ -8,8 +8,9 @@ interface MathNode extends MdastNode {
8
8
  /**
9
9
  * Satteri MDAST plugin that turns math nodes into Blume's `<Math>` component,
10
10
  * which renders them with KaTeX at build time. Block math (`$$…$$`) becomes a
11
- * block element; inline math (`$…$`) stays inline. Only active when math is
12
- * enabled in config, so `$` is otherwise left as literal text.
11
+ * block element. Blume runs the parser block-only (`singleDollarTextMath:
12
+ * false`), so a bare `$` stays literal and no `inlineMath` nodes are produced;
13
+ * the `inlineMath` visitor remains as a harmless safety net.
13
14
  */
14
15
  export const mathPlugin = () => ({
15
16
  inlineMath(node: MathNode, ctx: MdastVisitorContext) {
@@ -61,7 +61,7 @@ const themeConfiguration = (
61
61
  const accent = resolveAccent(config.theme);
62
62
  const radius = resolveRadius(config.theme);
63
63
  return {
64
- customCss: `:root,.light-mode,.dark-mode{--scalar-color-accent:${accent};--scalar-radius:${radius};}`,
64
+ customCss: `:root,.light-mode,.dark-mode{--scalar-color-accent:${accent.light};--scalar-radius:${radius};}.dark-mode{--scalar-color-accent:${accent.dark};}`,
65
65
  ...darkModeConfig(config.theme.mode),
66
66
  };
67
67
  };
@@ -12,6 +12,7 @@ import {
12
12
  buildRuntimeData,
13
13
  collectStaged,
14
14
  detectNeedsReact,
15
+ detectUsesMath,
15
16
  } from "../astro/generate.ts";
16
17
  import { discoverIslands } from "../astro/islands.ts";
17
18
  import { customOgRoutes, discoverPages, routeIsTaken } from "../astro/pages.ts";
@@ -102,19 +103,25 @@ export const eject = async (root: string): Promise<string[]> => {
102
103
  const exportPdf = config.export.pdf;
103
104
  const exportEpub = config.export.epub;
104
105
 
105
- const [pages, needsReactRaw, userTheme, rawMarkdown, islands, examples] =
106
- await Promise.all([
107
- context.pagesRoot
108
- ? discoverPages(context.pagesRoot)
109
- : Promise.resolve([]),
110
- detectNeedsReact(root),
111
- context.themeFile
112
- ? readFile(context.themeFile, "utf-8")
113
- : Promise.resolve(""),
114
- buildRawMarkdown(project),
115
- discoverIslands(root),
116
- discoverExamples(root, config.examples),
117
- ]);
106
+ const [
107
+ pages,
108
+ needsReactRaw,
109
+ usesMath,
110
+ userTheme,
111
+ rawMarkdown,
112
+ islands,
113
+ examples,
114
+ ] = await Promise.all([
115
+ context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
116
+ detectNeedsReact(root),
117
+ detectUsesMath(root),
118
+ context.themeFile
119
+ ? readFile(context.themeFile, "utf-8")
120
+ : Promise.resolve(""),
121
+ buildRawMarkdown(project),
122
+ discoverIslands(root),
123
+ discoverExamples(root, config.examples),
124
+ ]);
118
125
  // Island/example frameworks drive which Astro renderers the ejected config
119
126
  // wires in; React also switches on for project `.tsx`/`.jsx` and Ask AI.
120
127
  const frameworks = new Set<string>([
@@ -192,7 +199,7 @@ export const eject = async (root: string): Promise<string[]> => {
192
199
  askEnabled,
193
200
  exportEpub,
194
201
  exportPdf,
195
- mathEnabled: config.markdown.math,
202
+ mathEnabled: usesMath,
196
203
  needsReact,
197
204
  }),
198
205
  path: join(srcDir, "pages", "[...slug].astro"),
@@ -114,10 +114,15 @@ const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
114
114
  * Pass `includeWhenDisabled` to index pages on their content merits even when
115
115
  * the search provider is `none` — used by the MCP server, which is a separate
116
116
  * feature from on-page search.
117
+ *
118
+ * `content` selects the extraction: `"plain"` (default) strips Markdown to bare
119
+ * searchable text; `"markdown"` keeps the body's Markdown — code blocks, lists,
120
+ * headings — for Ask AI grounding, where fenced examples are often the answer
121
+ * and stripping them makes the model unable to cite content the docs do contain.
117
122
  */
118
123
  export const buildSearchDocuments = async (
119
124
  project: BlumeProject,
120
- options?: { includeWhenDisabled?: boolean }
125
+ options?: { includeWhenDisabled?: boolean; content?: "markdown" | "plain" }
121
126
  ): Promise<SearchDocument[]> => {
122
127
  const pageById = new Map(project.graph.pages.map((page) => [page.id, page]));
123
128
 
@@ -148,7 +153,9 @@ export const buildSearchDocuments = async (
148
153
  indexable.map(async (route) => {
149
154
  const page = pageById.get(route.id);
150
155
  const raw = page ? await readEntryText(project, page) : "";
151
- const body = raw ? toPlainText(matter(raw).content) : "";
156
+ const source = raw ? matter(raw).content : "";
157
+ const body =
158
+ options?.content === "markdown" ? source.trim() : toPlainText(source);
152
159
  const tags = page?.meta?.search?.tags;
153
160
  const crumb = crumbs.get(route.path);
154
161
  return {
@@ -435,6 +435,10 @@ blume-diff {
435
435
  font-size: 0.8125rem;
436
436
  }
437
437
 
438
+ .prose :where(td, th) :not(pre) > code {
439
+ white-space: nowrap;
440
+ }
441
+
438
442
  blume-tabs pre,
439
443
  .not-prose > div > pre {
440
444
  background: var(--blume-code-background);
@@ -610,9 +614,9 @@ pre:has(.line.focused):hover .line:not(.focused) {
610
614
  content: none;
611
615
  }
612
616
 
613
- /* Inline code highlighting (markdown.code.inline): Shiki colors the tokens of a
614
- \`code\`{:lang} snippet via the same dual-theme CSS variables as fenced blocks,
615
- keeping the inline pill background. */
617
+ /* Inline code highlighting: Shiki colors the tokens of a \`code\`{:lang} snippet
618
+ via the same dual-theme CSS variables as fenced blocks, keeping the inline
619
+ pill background. Always on — it only fires on the trailing {:lang} marker. */
616
620
  .prose code.blume-inline-code span {
617
621
  color: var(--shiki-light);
618
622
  }
@@ -67,10 +67,12 @@ const themeRootCss = (
67
67
  "--blume-action-foreground",
68
68
  options.action ? "oklch(1 0 0)" : null
69
69
  ),
70
- ...cssToken("--blume-background", safeColorOrNull(theme.background)),
70
+ ...cssToken("--blume-background", safeColorOrNull(theme.background?.light)),
71
71
  ...cssToken(
72
72
  "--blume-background-image",
73
- theme.backgroundImage ? backgroundImageCss(theme.backgroundImage) : null
73
+ theme.backgroundImage?.light
74
+ ? backgroundImageCss(theme.backgroundImage.light)
75
+ : null
74
76
  ),
75
77
  ` --blume-radius: ${options.radius};`,
76
78
  ]
@@ -96,11 +98,11 @@ const themeDarkCss = (
96
98
  "--blume-action-foreground",
97
99
  options.action ? "oklch(1 0 0)" : null
98
100
  ),
99
- ...cssToken("--blume-background", safeColorOrNull(theme.backgroundDark)),
101
+ ...cssToken("--blume-background", safeColorOrNull(theme.background?.dark)),
100
102
  ...cssToken(
101
103
  "--blume-background-image",
102
- theme.backgroundImageDark
103
- ? backgroundImageCss(theme.backgroundImageDark)
104
+ theme.backgroundImage?.dark
105
+ ? backgroundImageCss(theme.backgroundImage.dark)
104
106
  : null
105
107
  ),
106
108
  ].filter(Boolean);
@@ -111,12 +113,18 @@ ${tokens.join("\n")}
111
113
  };
112
114
 
113
115
  /**
114
- * Resolve the configured accent to a CSS color. A named accent resolves to its
115
- * preset; any other value is treated as a raw CSS color so users can pass
116
- * arbitrary colors without a config change.
116
+ * Resolve the configured accent to per-mode CSS colors. A named accent
117
+ * resolves to its preset; any other value is treated as a raw CSS color so
118
+ * users can pass arbitrary colors without a config change. A string accent
119
+ * has already been normalized by the config schema to the same color for
120
+ * both modes.
117
121
  */
118
- export const resolveAccent = (theme: ResolvedConfig["theme"]): string =>
119
- presetOrColor(theme.accent);
122
+ export const resolveAccent = (
123
+ theme: ResolvedConfig["theme"]
124
+ ): { dark: string; light: string } => ({
125
+ dark: presetOrColor(theme.accent.dark),
126
+ light: presetOrColor(theme.accent.light),
127
+ });
120
128
 
121
129
  /** Resolve the configured radius preset to a CSS length. */
122
130
  export const resolveRadius = (theme: ResolvedConfig["theme"]): string =>
@@ -128,17 +136,16 @@ export const resolveRadius = (theme: ResolvedConfig["theme"]): string =>
128
136
  * arbitrary colors without a config change.
129
137
  */
130
138
  export const buildThemeCss = (theme: ResolvedConfig["theme"]): string => {
131
- const accent = presetOrColor(theme.accent);
132
- const accentDark = theme.accentDark ? presetOrColor(theme.accentDark) : null;
139
+ const accent = resolveAccent(theme);
133
140
  const action = theme.action ? presetOrColor(theme.action) : null;
134
141
  const radius = RADII[theme.radius];
135
142
  const root = themeRootCss(theme, {
136
- accent,
143
+ accent: accent.light,
137
144
  action,
138
145
  radius,
139
146
  });
140
147
  const dark = themeDarkCss(theme, {
141
- accent: accentDark ?? accent,
148
+ accent: accent.dark,
142
149
  action,
143
150
  });
144
151