Skip to content

Commit 37a739b

Browse files
committed
fix: dual-theme shiki via CSS custom properties (user-decidable dark/light)
- rehype-shiki: set --shiki-light-bg/--shiki-dark-bg on <pre> - rehype-shiki: tokens get --shiki-light/--shiki-dark CSS vars - Token color redirected from inline to CSS vars (not hardcoded) - styles.css: light mode uses --shiki-light*, dark via prefers-color-scheme - styles.css: .dark class fallback for manual dark mode toggle - Users override via .svelte-streamdown pre in their own CSS - Matches upstream vercel/streamdown dual-theme approach
1 parent 8aab9ee commit 37a739b

2 files changed

Lines changed: 96 additions & 23 deletions

File tree

packages/svelte-streamdown/src/lib/rehype-shiki.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,31 @@ const ensureHighlighter = (): Promise<any> => {
2020
return highlighterReady;
2121
};
2222

23-
const htmlStyleToCSS = (htmlStyle: Record<string, string>): string =>
24-
Object.entries(htmlStyle)
25-
.map(([k, v]) => `${k}:${v}`)
26-
.join(";");
23+
/**
24+
* Build an inline style string from token.htmlStyle.
25+
* Redirects shiki's direct CSS properties to CSS custom properties
26+
* so our stylesheet can switch themes via media queries / .dark class.
27+
*
28+
* Light color → --shiki-light (used by CSS default)
29+
* Dark color → --shiki-dark (used by CSS dark mode)
30+
*/
31+
const tokenToStyle = (token: any): string | undefined => {
32+
if (!token.htmlStyle) return undefined;
33+
34+
const parts: string[] = [];
35+
for (const [key, value] of Object.entries(token.htmlStyle)) {
36+
if (key === "color") {
37+
// Redirect direct color to CSS var — our CSS sets `color: var(--shiki-light)`
38+
parts.push(`--shiki-light:${value}`);
39+
} else if (key === "background-color") {
40+
parts.push(`--shiki-light-bg:${value}`);
41+
} else {
42+
// Pass through CSS custom properties like --shiki-dark
43+
parts.push(`${key}:${value}`);
44+
}
45+
}
46+
return parts.length > 0 ? parts.join(";") : undefined;
47+
};
2748

2849
export const rehypeShiki = (options: RehypeShikiOptions = {}) => {
2950
const themes = options.themes ?? ["github-light", "github-dark"];
@@ -36,7 +57,6 @@ export const rehypeShiki = (options: RehypeShikiOptions = {}) => {
3657
return;
3758
}
3859

39-
// Collect pre > code pairs
4060
const codeNodes: Array<{ pre: any; code: any; lang: string }> = [];
4161
visit(tree, "element", (node: any) => {
4262
if (node.tagName !== "pre") return;
@@ -49,7 +69,6 @@ export const rehypeShiki = (options: RehypeShikiOptions = {}) => {
4969
codeNodes.push({ pre: node, code, lang });
5070
});
5171

52-
// Load languages on demand, skip invalid ones
5372
for (const { lang } of codeNodes) {
5473
if (!lang || lang === "text") continue;
5574
const loaded = h.getLoadedLanguages();
@@ -76,14 +95,29 @@ export const rehypeShiki = (options: RehypeShikiOptions = {}) => {
7695
themes: { light: themes[0], dark: themes[1] },
7796
});
7897

79-
// Set background color on <pre> so code blocks get shiki's bg
80-
// even when the page has a dark background
81-
if (result.bg) {
98+
// Set CSS custom properties on <pre> for theme switching.
99+
// Users override via .svelte-streamdown pre or @media queries.
100+
const preStyleParts: string[] = [];
101+
if (result.bg) preStyleParts.push(`--shiki-light-bg:${result.bg}`);
102+
if (result.fg) preStyleParts.push(`--shiki-light-fg:${result.fg}`);
103+
// result.rootStyle contains --shiki-dark-bg from the dark theme
104+
if (result.rootStyle) {
105+
for (const decl of result.rootStyle.split(";")) {
106+
const idx = decl.indexOf(":");
107+
if (idx > 0) {
108+
const prop = decl.slice(0, idx).trim();
109+
const val = decl.slice(idx + 1).trim();
110+
if (prop && val) preStyleParts.push(`${prop}:${val}`);
111+
}
112+
}
113+
}
114+
115+
if (preStyleParts.length > 0) {
82116
if (!pre.properties) pre.properties = {};
83-
pre.properties.style = `background-color:${result.bg}`;
117+
pre.properties.style = preStyleParts.join(";");
84118
}
85119

86-
// Highlight tokens with dual-theme CSS vars
120+
// Highlight tokens
87121
code.children = result.tokens.map((line: any) => ({
88122
type: "element",
89123
tagName: "span",
@@ -92,9 +126,7 @@ export const rehypeShiki = (options: RehypeShikiOptions = {}) => {
92126
type: "element",
93127
tagName: "span",
94128
properties: {
95-
style: token.htmlStyle
96-
? htmlStyleToCSS(token.htmlStyle)
97-
: undefined,
129+
style: tokenToStyle(token),
98130
},
99131
children: [{ type: "text", value: token.content }],
100132
})),

packages/svelte-streamdown/styles.css

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,6 @@
4444
white-space: pre;
4545
}
4646

47-
.svelte-streamdown .shiki .line {
48-
display: block;
49-
}
50-
51-
/* shiki sets background-color on <pre> for light theme */
52-
.svelte-streamdown :where(pre.shiki, pre:has(code.shiki)) {
53-
color: var(--shiki-dark, #24292e);
54-
}
55-
5647
.svelte-streamdown :where(table) {
5748
border-collapse: collapse;
5849
width: 100%;
@@ -72,3 +63,53 @@
7263
margin-inline-start: 0.125rem;
7364
opacity: 0.7;
7465
}
66+
67+
/* ── Shiki dual-theme support ──
68+
*
69+
* rehype-shiki sets these CSS custom properties on <pre>:
70+
* --shiki-light-bg, --shiki-light-fg (from light theme)
71+
* --shiki-dark-bg, --shiki-dark (from dark theme)
72+
*
73+
* Token spans get:
74+
* --shiki-light (light theme color)
75+
* --shiki-dark (dark theme color)
76+
*
77+
* Theme switching is automatic via prefers-color-scheme.
78+
* Override by targeting .svelte-streamdown pre in your own CSS.
79+
*/
80+
81+
/* Light mode (default) */
82+
.svelte-streamdown :where(pre:has(code.shiki)) {
83+
background-color: var(--shiki-light-bg, transparent);
84+
color: var(--shiki-light-fg, inherit);
85+
}
86+
87+
.svelte-streamdown .shiki .line {
88+
display: block;
89+
}
90+
91+
.svelte-streamdown .shiki .line span {
92+
color: var(--shiki-light, inherit);
93+
}
94+
95+
/* Dark mode — system preference */
96+
@media (prefers-color-scheme: dark) {
97+
.svelte-streamdown :where(pre:has(code.shiki)) {
98+
background-color: var(--shiki-dark-bg, var(--shiki-light-bg, transparent));
99+
color: var(--shiki-dark-fg, var(--shiki-light-fg, inherit));
100+
}
101+
102+
.svelte-streamdown .shiki .line span {
103+
color: var(--shiki-dark, var(--shiki-light, inherit));
104+
}
105+
}
106+
107+
/* Dark mode — manual .dark class on ancestor */
108+
.dark .svelte-streamdown :where(pre:has(code.shiki)) {
109+
background-color: var(--shiki-dark-bg, var(--shiki-light-bg, transparent));
110+
color: var(--shiki-dark-fg, var(--shiki-light-fg, inherit));
111+
}
112+
113+
.dark .svelte-streamdown .shiki .line span {
114+
color: var(--shiki-dark, var(--shiki-light, inherit));
115+
}

0 commit comments

Comments
 (0)