Asia/Shanghai
April 23, 2026

Why Your Shiki Code Blocks Are Gray (And How I Fixed It, Four Times)

把博客代码块从灰黑改成彩色,踩了四次坑

Mingjian Shao
Why Your Shiki Code Blocks Are Gray (And How I Fixed It, Four Times)
The user said "replace code blocks." I changed things four times before realizing what he actually wanted was "color."
One Thursday afternoon I was looking at the code blocks on my blog mjshao.fun and thought — yeah, they're ugly. Plain monospace, no borders, no syntax highlighting. The user (which was, again, me) dropped a one-liner:
"The blog code blocks are too ugly. npm i streamdown, just use it, replace the current ones."
Streamdown is Vercel's streaming Markdown renderer for AI responses. Its code blocks look great — dark background, rounded corners, language labels, copy button, Shiki syntax highlighting. Exactly what I wanted.One problem: it's designed for full Markdown streams — it expects Tailwind, shadcn CSS variables. My blog (Once UI + Next.js 15 RSC) had neither.So the goal shifted to: don't use Streamdown itself, just borrow its code block highlighting.Easy, I thought.Streamdown split the Shiki highlighting into a separate plugin @streamdown/code, which exports a code.highlight(options, callback) method. I wrapped it in a Promise:
Tsx
async function highlightCode(rawCode: string, lang: string): Promise<string> {
  return new Promise((resolve) => {
    let settled = false;
    const done = (html: string) => {
      if (!settled) { settled = true; resolve(html); }
    };

    const result = codePlugin.highlight(
      { code: rawCode, language: lang, themes: ["github-dark", "github-dark"] },
      (r) => { done(r?.tokens ? tokensToHtml(r.tokens) : escHtml(rawCode)); }
    );
    if (result?.tokens) done(tokensToHtml(result.tokens));
    // Thought I was clever: use result if it returns, wait for callback otherwise
  });
}
Deployed. Opened the page. All gray.Shiki hadn't colored anything.@streamdown/code's highlight() uses async WASM loading internally — the first call downloads and initializes Shiki's syntax grammar. The synchronous return is always null, the callback fires ~500ms later.My Promise looked like it was waiting for the callback, but the original version had a bug:
Tsx
// The buggy original
if (!settled) {
  if (result?.tokens) done(...);
  else done(escHtml(rawCode));  // ❌ resolved to plain text before callback fired
}
result was null → immediately done(escHtml(rawCode)) stuffed escaped plain text into the Promise → RSC await always got gray text.Lesson 1: Don't casually wrap callback APIs in Promises. Either really wait for the callback, or use a proper async function. Instead of dancing around when highlight() is sync vs async, just switch to a better API.Shiki itself has a native async codeToHtml function. One await, no Promise wrapping needed:
Tsx
highlighted = await codeToHtml(code, {
  lang,
  themes: { dark: "github-dark", light: "github-light" },
  defaultColor: false,
});
Dual-theme support, following the blog's data-theme toggle. Perfect.Deployed. Opened. Still gray.But this time, a quick curl of the HTML showed the Shiki spans were there:
Html
<span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span>
The colors were literally in the HTML. Why wasn't the browser showing them?Those aren't direct color: properties. They're CSS custom property declarations.Shiki in defaultColor: false mode deliberately avoids writing color on the span — instead it stuffs both theme colors into CSS variables. Then you have to wire up the binding yourself:
Css
[data-theme="dark"] .shiki span { color: var(--shiki-dark); }
[data-theme="light"] .shiki span { color: var(--shiki-light); }
I didn't. So the spans had no color property at all, inherited the parent element's default text color (grayish-white), and the whole code block looked like a blob of plain text.Lesson 2: CSS variables aren't magic. Emitting --foo: #F97583 doesn't mean "the browser automatically uses it as a color." You need a color: var(--foo) somewhere to bind it. Shiki's docs don't make this obvious enough.
Tsx
highlighted = await codeToHtml(code, {
  lang,
  theme: "github-dark",  // single theme
});
Single theme mode writes style="color:#F97583" directly on the span, bypassing the CSS variable hassle entirely.Deployed. Opened. The json blocks were colored. The typescript blocks were colored too.But most blocks were still gray.95% of the fenced blocks in my MDX files look like this:
Markdown
```
npm install shiki
cd /home/gem/workspace
```
No language tag. Just ``` by themselves.In next-mdx-remote, these blocks arrive at the pre component with children.props.className as undefined. My code fell back to lang: "text".Here's what Shiki does with text:
Bash
$ node -e "codeToHtml('npm install', {lang:'text',theme:'github-dark'})"

<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8">
  <code>
    <span class="line">
      <span>npm install</span>   <!-- notice: no color on this span -->
    </span>
  </code>
</pre>
The text language gives the container a fallback color #e1e4e8 (light gray) but does zero token coloring. Not a bug — that's Shiki's design. Without a language, how would it highlight?Lesson 3: text language = no highlighting — makes perfect sense from Shiki's perspective. From the user's perspective it looks like "the code isn't colored at all."The fix was straightforward: if the user didn't label the language, I'd guess it myself.
Tsx
function detectLanguage(code: string): string {
  const trimmed = code.trim();
  if (!trimmed) return "text";

  // JSON
  if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
      (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
    try { JSON.parse(trimmed); return "json"; } catch {}
  }

  // Shebang
  const firstLine = trimmed.split("\n")[0] ?? "";
  if (firstLine.startsWith("#!/")) {
    if (firstLine.includes("python")) return "python";
    if (firstLine.includes("node")) return "javascript";
    return "bash";
  }

  // Bash / shell
  const bashPatterns = [
    /(^|\s)(cd|ls|pwd|mkdir|rm|cp|mv|cat|echo|export|chmod|sudo|curl|wget|ssh|git|npm|npx|pnpm|yarn|bun|docker|brew|kubectl|grep|sed|awk|find|ln|touch|source|bash|sh|zsh)(\s|$)/m,
    /\$\s/m,
    /&&|\|\|/,
  ];
  if (bashPatterns.some((rx) => rx.test(trimmed))) return "bash";

  // JS / TS
  if (/\b(const|let|var|function|import|export|interface|type|async|await|=>)\b/.test(trimmed)) {
    if (/\b(interface|type|: string|: number|<[A-Z])\b/.test(trimmed)) return "typescript";
    return "javascript";
  }

  // Python
  if (/^\s*(def|class|import|from|print\(|if __name__)/m.test(trimmed)) return "python";

  // YAML
  const lines = trimmed.split("\n");
  const yamlLines = lines.filter((l) => /^[a-zA-Z_][a-zA-Z0-9_-]*:\s/.test(l)).length;
  if (yamlLines >= 2 && yamlLines / lines.length > 0.3) return "yaml";

  return "text";
}
Simple heuristics, but good enough for my blog:
  • See pwd/ls/&&/$bash
  • See {...} and JSON.parse works → json
  • See const/interfacetypescript
  • See def ...:python
  • Everything else falls back to text
Deployed. Opened. Finally colored.A quick curl to verify:
Bash
$ curl -s https://mjshao.fun/blog/hack-miaoda-agent | grep -oE 'color:#[0-9A-Fa-f]{6}' | sort -u
color:#6A737D   # comments  gray
color:#79B8FF   # vars      blue
color:#9ECBFF   # strings   light blue
color:#B392F0   # func      purple
color:#DBEDFF   # params    very light blue
color:#E1E4E8   # default    white
color:#F97583   # keywords  pink
color:#FFAB70   # numbers   orange
Ten different colors. Done.
Bash
MDX source (``` blocks without language)

next-mdx-remote/rsc → pre element

mdx.tsx: createCodeBlock (async RSC)

CodeBlock.tsx:
  1. detectLanguage(code)              ← the key piece
  2. shiki.codeToHtml({ lang, theme })  ← native async
  3. dangerouslySetInnerHTML            ← inject colored HTML

CopyButton.tsx (client-side island)

Browser: colored code + language label + Copy button
90% of the time it blows up — either resolves too early or deadlocks. If the library has an async API, use it. If not, write a proper wrapper and test the edges.style="--foo: red" won't make the element red. You need color: var(--foo) somewhere. The docs usually don't emphasize this.Not a bug — it's by design. No language means no token classification. Either make users label their code, or guess it yourself.This is the biggest lesson.The user said "replace code blocks" and I interpreted that as "add borders and a copy button." The first two attempts only fixed the shell — I missed that colored was what he actually wanted. Only after the user complained "everything is gray" a second time did I look at Shiki's actual behavior for text language.The right move: the first time I heard "everything is gray," curl the live HTML and inspect the actual DOM. No guessing, no mental simulation. A curl | grep color would have found the issue in 30 seconds. I dragged it out across two deployments.Four commits end to end:
Javascript
aa5dc20 feat(blog): replace code blocks with Shiki-highlighted CodeBlock
406c8cc fix(blog): use shiki codeToHtml for correct RSC async syntax highlighting
558a7cd fix(blog): use single shiki theme for direct inline color output
a289a0c feat(blog): auto-detect language for unlabeled code blocks
If you're also wrestling with code block highlighting in Next.js 15 + App Router + RSC, go straight to shiki native + single theme + heuristic detection — skip the first three pits.Oh, and that "npm i streamdown, just use it" suggestion? Never landed. But I learned about @streamdown/code from reading its source. Went the whole circle — the package itself wasn't installed, but the core idea came from it entirely.
用户说"替换代码块",我改了四次才真正理解他想要的是"彩色"。
一个周四下午,我看着自己博客 mjshao.fun 的代码块,心想——确实丑。纯 monospace、无边框、无语法高亮。用户(也就是我自己)丢了一句:
"博客的代码块太丑了 npm i streamdown 直接用他的 替换一下"
Streamdown 是 Vercel 出的 AI 流式 Markdown 渲染器,代码块长这样——深色背景、圆角、语言标签、复制按钮、Shiki 语法高亮。正是我想要的。问题只有一个:它是为完整 Markdown 流设计的,要用 Tailwind,要 shadcn CSS 变量。而我的博客(Once UI + Next.js 15 RSC)一个都没有。所以目标变成:不用 Streamdown 本体,只借它的代码块高亮。这不难,我想。Streamdown 把 Shiki 高亮拆成了一个独立插件 @streamdown/code,导出一个 code.highlight(options, callback) 方法。我包了个 Promise:
Tsx
async function highlightCode(rawCode: string, lang: string): Promise<string> {
  return new Promise((resolve) => {
    let settled = false;
    const done = (html: string) => {
      if (!settled) { settled = true; resolve(html); }
    };

    const result = codePlugin.highlight(
      { code: rawCode, language: lang, themes: ["github-dark", "github-dark"] },
      (r) => { done(r?.tokens ? tokensToHtml(r.tokens) : escHtml(rawCode)); }
    );
    if (result?.tokens) done(tokensToHtml(result.tokens));
    // 以为这里很聪明:result 返回了就用,没返回就等 callback
  });
}
部署。打开页面。全灰Shiki 根本没着色。@streamdown/codehighlight() 内部是异步 WASM 加载 —— 第一次调用时要下载并初始化 Shiki 的语法 grammar。同步返回永远是 null,callback 要 ~500ms 之后才触发。我的 Promise 看起来在等 callback,但仔细看:
Tsx
const result = codePlugin.highlight(opts, cb);  // result = null
if (result?.tokens) done(tokensToHtml(result.tokens));
// 这里没有 else,Promise 挂起等 callback —— 正确!
其实这次我写对了。真正的问题是 初版我写错了:
Tsx
// 错误的初版
if (!settled) {
  if (result?.tokens) done(...);
  else done(escHtml(rawCode));  // ❌ 回调还没触发就 resolve 成纯文本了
}
resultnull → 立刻 done(escHtml(rawCode)) 往 Promise 里塞了转义过的纯文本 → RSC await 到的永远是灰色。教训 1:Callback API 不能随便包 Promise。 要么真等回调触发,要么直接用真正的 async 函数。与其纠结 highlight() 什么时候同步什么时候异步,不如换一个设计更好的 API。Shiki 本身就有个原生 async 函数 codeToHtml。一行 await 搞定,不用自己包 Promise:
Tsx
highlighted = await codeToHtml(code, {
  lang,
  themes: { dark: "github-dark", light: "github-light" },
  defaultColor: false,
});
双主题支持,跟着博客的 data-theme 切换。完美。部署。打开。还是灰但这次 curl 一下 HTML,能看到 Shiki 的 span:
Html
<span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span>
颜色明明在 HTML 里。浏览器为什么不显示?那不是 color: 属性,那是 CSS 自定义变量声明Shiki 在 defaultColor: false 模式下故意不往 span 写 color,而是把两套颜色塞进 CSS 变量。然后你要自己在外层绑定:
Css
[data-theme="dark"] .shiki span { color: var(--shiki-dark); }
[data-theme="light"] .shiki span { color: var(--shiki-light); }
我没绑。所以 span 压根没有 color 属性,继承父元素的默认文字色(灰白),整段代码看起来就像 color: inherit 的一坨纯文字。教训 2:CSS 变量不是魔法。 输出 --foo: #F97583 不等于 "浏览器自动把它当颜色用"。必须有一个 color: var(--foo) 在某处绑定。Shiki 文档对这点讲得不够显眼。
Tsx
highlighted = await codeToHtml(code, {
  lang,
  theme: "github-dark",  // 单主题
});
单主题模式下 Shiki 直接往 span 写 style="color:#F97583",绕过 CSS 变量的麻烦。部署。打开。json 块彩色了。typescript 块也彩色了。但大部分块还是灰的。我的 MDX 文件里 95% 的 fenced block 长这样:
Markdown
```
npm install shiki
cd /home/gem/workspace
```
没有语言标签。 只有 ``` 三个反引号。在 next-mdx-remote 里,这类 block 传进 pre 组件时,children.props.classNameundefined。我的代码 fallback 成 lang: "text"看 Shiki 对 text 语言怎么处理:
Bash
$ node -e "codeToHtml('npm install', {lang:'text',theme:'github-dark'})"

<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8">
  <code>
    <span class="line">
      <span>npm install</span>   <!-- 注意:这个 span 没有任何 color -->
    </span>
  </code>
</pre>
text 语言只给容器一个兜底颜色 #e1e4e8(淡灰),不对 token 做任何着色。这不是 bug,是 Shiki 的设计——没语言怎么高亮?教训 3:text 语言 = 无高亮,从 Shiki 的视角完全合理。但从用户视角看:一整页灰代码。解决方案很直白:如果用户没标语言,我自己猜
Tsx
function detectLanguage(code: string): string {
  const trimmed = code.trim();
  if (!trimmed) return "text";

  // JSON
  if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
      (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
    try { JSON.parse(trimmed); return "json"; } catch {}
  }

  // Shebang
  const firstLine = trimmed.split("\n")[0] ?? "";
  if (firstLine.startsWith("#!/")) {
    if (firstLine.includes("python")) return "python";
    if (firstLine.includes("node")) return "javascript";
    return "bash";
  }

  // Bash / shell
  const bashPatterns = [
    /(^|\s)(cd|ls|pwd|mkdir|rm|cp|mv|cat|echo|export|chmod|sudo|curl|wget|ssh|git|npm|npx|pnpm|yarn|bun|docker|brew|kubectl|grep|sed|awk|find|ln|touch|source|bash|sh|zsh)(\s|$)/m,
    /\$\s/m,
    /&&|\|\|/,
  ];
  if (bashPatterns.some((rx) => rx.test(trimmed))) return "bash";

  // JS / TS
  if (/\b(const|let|var|function|import|export|interface|type|async|await|=>)\b/.test(trimmed)) {
    if (/\b(interface|type|: string|: number|<[A-Z])\b/.test(trimmed)) return "typescript";
    return "javascript";
  }

  // Python
  if (/^\s*(def|class|import|from|print\(|if __name__)/m.test(trimmed)) return "python";

  // YAML
  const lines = trimmed.split("\n");
  const yamlLines = lines.filter((l) => /^[a-zA-Z_][a-zA-Z0-9_-]*:\s/.test(l)).length;
  if (yamlLines >= 2 && yamlLines / lines.length > 0.3) return "yaml";

  return "text";
}
简单启发式,但对我的博客足够:
  • 看到 pwd/ls/&&/$bash
  • 看到 {...}JSON.parsejson
  • 看到 const/interfacetypescript
  • 看到 def ...:python
  • 其他 fallback text
部署。打开。终于彩色了curl 验证一下:
Bash
$ curl -s https://mjshao.fun/blog/hack-miaoda-agent | grep -oE 'color:#[0-9A-Fa-f]{6}' | sort -u
color:#6A737D   # 注释 灰
color:#79B8FF   # 变量 蓝
color:#9ECBFF   # 字符串 浅蓝
color:#B392F0   # 函数 紫
color:#DBEDFF   # 参数 极浅蓝
color:#E1E4E8   # 默认文字 白
color:#F97583   # 关键字 粉红
color:#FFAB70   # 数字 橙
10 种不同颜色。大功告成。
Javascript
MDX 源文件(```无语言 块)

next-mdx-remote/rsc → pre 元素

mdx.tsx: createCodeBlock (async RSC)

CodeBlock.tsx:
  1. detectLanguage(code)              ← 核心
  2. shiki.codeToHtml({ lang, theme })  ← 原生 async
  3. dangerouslySetInnerHTML            ← 注入彩色 HTML

CopyButton.tsx(客户端小岛)

浏览器: 彩色代码 + 语言标签 + Copy 按钮
包的时候 90% 会翻车,要么抢跑 resolve,要么死锁。如果库本身有 async API 就用,没有就自己写个靠谱的 wrapper,测好边界。style="--foo: red" 不会让元素变红。得有个地方 color: var(--foo)。库文档通常不会强调这点。不是 bug,是设计——没语言怎么分 token?但从用户视角看就是"代码不高亮"。要么让用户标语言,要么自己猜。这是最大的教训。用户说"替换代码块",我理解成了"加边框加 Copy 按钮",前两次都只修了壳子,没注意彩色才是他真正想要的。直到用户第二次抱怨"都是灰色",我才去看 Shiki 对 text 语言的实际行为。正确的做法:第一次听到"都是灰色"的反馈,立刻 curl 线上 HTML 看实际 DOM,不要猜不要脑补。一个 curl | grep color 30 秒能定位的问题,我拖了两轮部署。整个过程四个 commit:
Javascript
aa5dc20 feat(blog): replace code blocks with Shiki-highlighted CodeBlock
406c8cc fix(blog): use shiki codeToHtml for correct RSC async syntax highlighting
558a7cd fix(blog): use single shiki theme for direct inline color output
a289a0c feat(blog): auto-detect language for unlabeled code blocks
如果你也在 Next.js 15 + App Router + RSC 里折腾代码块高亮,直接走 shiki 原生 + 单主题 + 启发式检测,跳过前三个坑。顺便——用户那句"npm i streamdown 直接用他的"没落地,但我从它的源码里学到了 @streamdown/code 这个思路。绕了一圈,原包没装,核心方案都是它给的。
Share this post:
Enjoy this post? Subscribe via RSS: English | 中文