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.
- package/dist/cli/index.js +2996 -2762
- package/dist/cli/index.js.map +35 -32
- package/dist/types/core/package-json.d.ts +12 -0
- package/dist/types/migrate/shared.d.ts +153 -0
- package/docs/advanced/migrate.mdx +1 -0
- package/docs/configuration/ai.mdx +1 -1
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/sources.mdx +1 -1
- package/package.json +1 -1
- package/src/ai/mcp/discovery.ts +3 -1
- package/src/ai/mcp/server.ts +3 -1
- package/src/astro/component-slots.ts +10 -2
- package/src/astro/generate.ts +11 -1
- package/src/astro/static-assets.ts +10 -3
- package/src/astro/templates.ts +87 -29
- package/src/cli/coalesce.ts +43 -0
- package/src/cli/commands/dev.ts +31 -17
- package/src/cli/commands/init.ts +2 -27
- package/src/cli/dev-lock.ts +4 -2
- package/src/components/content/ColorItem.astro +6 -3
- package/src/components/content/Prompt.astro +7 -3
- package/src/components/content/Tabs.astro +13 -2
- package/src/components/content/mermaid-element.ts +20 -2
- package/src/components/islands/ask-ai.tsx +4 -8
- package/src/components/islands/base-path.ts +30 -0
- package/src/components/islands/hooks.ts +12 -8
- package/src/components/layout/PageActions.astro +17 -11
- package/src/components/layout/Search.astro +4 -1
- package/src/components/layout/search/types.ts +16 -5
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/SchemaProperty.astro +1 -1
- package/src/components/openapi/SchemaTable.astro +3 -3
- package/src/components/openapi/helpers.ts +17 -8
- package/src/components/openapi/snippets.ts +17 -4
- package/src/core/config.ts +15 -6
- package/src/core/graph.ts +6 -1
- package/src/core/navigation.ts +5 -1
- package/src/core/package-json.ts +32 -0
- package/src/core/sources/filesystem.ts +19 -1
- package/src/core/sources/mdx-remote.ts +20 -4
- package/src/core/sources/mintlify.ts +30 -1
- package/src/core/sources/normalize.ts +28 -6
- package/src/core/sources/watch.ts +44 -0
- package/src/markdown/code-title.ts +6 -3
- package/src/markdown/package-install.ts +3 -1
- package/src/migrate/fumadocs/content.ts +3 -5
- package/src/migrate/fumadocs/index.ts +24 -9
- package/src/migrate/mintlify/config.ts +2 -6
- package/src/migrate/mintlify/index.ts +143 -32
- package/src/migrate/mintlify/snippets.ts +17 -8
- package/src/migrate/nextra/index.ts +16 -1
- package/src/migrate/shared.ts +101 -5
- package/src/migrate/starlight/content.ts +3 -6
- package/src/og/card.ts +16 -4
- package/src/openapi/render-mdx.ts +10 -1
- package/src/search/sync/orama-cloud.ts +2 -0
- package/src/search/sync/typesense.ts +4 -0
- package/src/theme/icons.ts +13 -4
- 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
|
-
|
|
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 =
|
|
190
|
+
const readFile =
|
|
191
|
+
options.readFile ??
|
|
192
|
+
((path: string): Promise<string> => readFileFromDisk(path, "utf-8"));
|
|
193
193
|
try {
|
|
194
|
-
|
|
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(", ")}.`
|
package/src/migrate/shared.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
"{": "{",
|
|
22
22
|
"}": "}",
|
|
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
|
|
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,
|
package/src/theme/icons.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/theme/palette.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
const
|
|
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,
|
|
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 {
|