Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/shiki-style-to-class.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,48 @@ describe("shikiStyleToClass", () => {
expect(uniqueClasses.size).toBe(2);
});

it("emits CSS for reused colors on later code blocks", () => {
const transformer = shikiStyleToClass();

const style =
"color:light-dark(#D73A49, #F47067);--shiki-light:#D73A49;--shiki-dark:#F47067";

const firstRoot = makeRoot([{ style }]);
transformer.root.call({} as any, firstRoot as any);

const firstSpan = firstRoot.children[0].children[0].children[0];
const cls = firstSpan.properties.class;

const secondRoot = makeRoot([{ style }]);
transformer.root.call({} as any, secondRoot as any);

const secondPre = secondRoot.children[0];
expect(secondPre.properties["data-shiki-css"]).toContain(
`.${cls}{${style}}`,
);
});

it("canonicalizes CSS rule order for equivalent blocks", () => {
const transformer = shikiStyleToClass();

const style1 =
"color:light-dark(#D73A49, #F47067);--shiki-light:#D73A49;--shiki-dark:#F47067";
const style2 =
"color:light-dark(#24292E, #ADBAC7);--shiki-light:#24292E;--shiki-dark:#ADBAC7";

const firstRoot = makeRoot([{ style: style1 }, { style: style2 }]);
transformer.root.call({} as any, firstRoot as any);

const secondRoot = makeRoot([{ style: style2 }, { style: style1 }]);
transformer.root.call({} as any, secondRoot as any);

const firstPre = firstRoot.children[0];
const secondPre = secondRoot.children[0];
expect(secondPre.properties["data-shiki-css"]).toBe(
firstPre.properties["data-shiki-css"],
);
});

it("preserves non-color style properties", () => {
const transformer = shikiStyleToClass();

Expand Down
45 changes: 20 additions & 25 deletions src/shiki-style-to-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
*
* 1. BUILD TIME (Shiki transformer — `shikiStyleToClass`):
* Runs during MDX compilation. Walks every token <span>, extracts color
* styles into a global Map, replaces `style` with `class`, and stores
* the CSS rules as a `data-shiki-css` attribute on the <pre> element.
* Data attributes survive RSC serialization (raw <style> HAST elements
* get stripped by the MDX/RSC pipeline).
* styles into a shared class registry, replaces `style` with `class`, and
* stores the CSS rules needed by that code block as a `data-shiki-css`
* attribute on the <pre> element. Data attributes survive RSC
* serialization (raw <style> HAST elements get stripped by the MDX/RSC
* pipeline).
*
* 2. RUNTIME (inline script — `injectShikiColors`):
* A tiny self-executing script injected via `_layout.tsx`. Creates a single
Expand All @@ -28,9 +29,9 @@
* to transform. The hook never fires for rendered pages.
* - HAST <style> sibling: MDX component overrides in Vocs don't map <style>
* elements, so they get stripped during React rendering.
* - Per-block scoped styles: Works but creates N <style> tags. The global map
* approach deduplicates across all blocks — only the first block to encounter
* a new color combination emits its CSS rule.
* - Per-block scoped styles: Works but creates N <style> tags. This approach
* keeps shared class names across the build, but each block still carries
* the rules it needs so a page never depends on another page's code block.
*
* @see https://github.com/shikijs/shiki/issues/671#issuecomment-3605208867
*/
Expand Down Expand Up @@ -96,17 +97,6 @@ function walkSpans(node: HastNode, cb: (span: HastNode) => void) {
}
}

/**
* Global color→class registry, shared across ALL code blocks at build time.
*
* The same Shiki themes produce the same ~8 color strings site-wide, so a
* global map means each unique style only generates one CSS class and one
* CSS rule. Subsequent blocks that use the same color reuse the existing
* class without emitting duplicate rules.
*/
const colorToClass = new Map<string, string>();
let classIndex = 0;

/**
* Shiki transformer (build time).
*
Expand All @@ -118,9 +108,12 @@ let classIndex = 0;
* 2. Walk every <span> with an inline style
* 3. Split the style into color vs. non-color parts
* 4. Replace color styles with a class name (from the global map)
* 5. Store any *new* CSS rules as `data-shiki-css` on the <pre>
* 5. Store the CSS rules used by this block as `data-shiki-css` on the <pre>
*/
export function shikiStyleToClass() {
const colorToClass = new Map<string, string>();
let classIndex = 0;

return {
name: "mpp:style-to-class",
enforce: "post" as const,
Expand All @@ -135,8 +128,8 @@ export function shikiStyleToClass() {
);
if (!code) return;

// Collect CSS rules for colors we haven't seen before
const newRules: [string, string][] = [];
// Collect CSS rules needed by this block.
const blockRules = new Map<string, string>();

walkSpans(code, (span: HastNode) => {
const style =
Expand All @@ -153,8 +146,8 @@ export function shikiStyleToClass() {
if (!cls) {
cls = `sc${classIndex++}`;
colorToClass.set(color, cls);
newRules.push([color, cls]);
}
blockRules.set(cls, color);

// Replace inline style with class reference
const spanClasses = span.properties.class
Expand All @@ -170,10 +163,12 @@ export function shikiStyleToClass() {
}
});

// No new colors in this block — nothing to emit
if (newRules.length === 0) return;
if (blockRules.size === 0) return;

const css = newRules.map(([style, cls]) => `.${cls}{${style}}`).join("");
const css = Array.from(
Array.from(blockRules.entries()).sort(([a], [b]) => a.localeCompare(b)),
([cls, style]) => `.${cls}{${style}}`,
).join("");
Comment on lines +168 to +171
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Canonicalize emitted rule order before joining CSS

The new data-shiki-css generation uses blockRules insertion order, which depends on the first token color encountered in each block. Two blocks that use the same set of classes in different token orders therefore emit different CSS strings, and the runtime dedupe (seen.has(css) in injectShikiColors) no longer collapses them. In pages with many code blocks or after client-side navigation, this causes repeated duplicate rules to accumulate in the shared <style> tag and degrades render performance; sorting by class name (or deduping per rule) before join keeps dedupe effective.

Useful? React with 👍 / 👎.


// Attach CSS rules to the <pre> as a data attribute.
//
Expand Down
Loading