这篇写于旧博客(Next.js + MDX)时期。迁移到现在的静态站后,这些功能都在构建时完成:TradingView 变成行情卡片,点「实时图表」才加载实时行情;ECharts 变成静态 SVG 图表,悬停显示数值,源码引用是带行号的固定 commit 片段,语言图标换成了代码块角落的语言名。

这次主要把博客的写作体验继续往前推了一步:代码块更容易扫读,代码引用能直接指向 GitHub,金融类内容可以用很短的语法插入 TradingView 图表。

先看新功能

代码块有语言图标

代码块右上角一直浮着语言 logo 和复制按钮,没有标题栏、没有徽章边框,不占额外空间。写了 title 或者代码来自 GitHub 时,代码上方只会多一行小字路径,GitHub 跳转入口收进右上角的一个小图标里。

const feature = '语言图标'
const theme = 'catppuccin'
console.log(feature, theme)
ts

inline code 重新做了主题

正文里的 git log --follow、$AAPL、::tv AAPL 不再像一块突兀的亮色贴片。明亮模式下它更接近 Catppuccin Latte 的纸面色,黑暗模式下则压进 Mocha 的低亮度底色。

直接引用 GitHub 代码

写作时输入:

::github-code repo="mizorewww/blog" ref="c067042276d4b4b384c66c0c61fcd4f6716eb599" path="contentlayer.config.ts" lines="251-253" lang="ts" title="contentlayer.config.ts"
md

渲染出来就是带 Shiki 高亮、语言图标和 GitHub 链接的代码块:

contentlayer.config.tsL251–253 · c067042 ↗
251const githubEmbedPattern = /^::github-(code|diff)\s+(.+)$/
252const tradingViewMiniPattern = /^\$([A-Za-z0-9._:-]+)$/
253const tradingViewAdvancedPattern = /^::(?:tv|tv-advanced|tradingview)\s+(.+)$/

diff 像 GitHub 一样分行染色

写作时输入:

::github-diff repo="mizorewww/blog" ref="c067042276d4b4b384c66c0c61fcd4f6716eb599" path="css/prism.css" lines="1-15"
md

渲染效果:

css/prism.css 在 c067042 中的改动

@@ -8,6 +8,44 @@ figure[data-rehype-pretty-code-figure] [data-rehype-pretty-code-title] {
   @apply flex items-center justify-between gap-3 border-b border-slate-200 bg-white/70 px-4 py-2 text-xs text-slate-600 dark:border-[#405064] dark:bg-white/[0.035] dark:text-white/65;
 }
 
+figure[data-rehype-pretty-code-figure] .code-title-main {
+  @apply inline-flex min-w-0 items-center gap-2;
+}
+
+figure[data-rehype-pretty-code-figure] .code-language-icon {
+  @apply inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-[5px] border border-slate-300 bg-slate-100 px-1 font-sans text-[0.62rem] leading-none font-semibold text-slate-600 dark:border-white/15 dark:bg-white/10 dark:text-white/70;
+}
+
+figure[data-rehype-pretty-code-figure] .code-language-icon[data-code-language='ts'],
+figure[data-rehype-pretty-code-figure] .code-language-icon[data-code-language='tsx'] {
+  @apply border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-400/25 dark:bg-sky-400/10 dark:text-sky-200;
diff

单行 ticker 自动变成 Mini Chart

写作时单独一行输入:

$AAPL
$BINANCE:BTCUSDT.P
md

旧站上这两行会各变成一张 Mini Chart。现在的静态站不加载 TradingView 脚本,改成一张链到 TradingView 的行情卡片:

NASDAQ:AAPL苹果公司TradingView ↗
BINANCE:BTCUSDT.P比特币 / USDT 永续合约TradingView ↗

Advanced Chart 也有短语法

写作时输入:

::tv AAPL interval=60 height=460
md

旧站上这里是一张可交互的 Advanced Chart,现在同样是一张行情卡片。

代码块直接渲染 ECharts

写文章时用 ```echarts 代码块,内容是图表的 option JSON,meta 支持 title 和 height:

```echarts title="每周访问量" height=320
{
  "tooltip": { "trigger": "axis" },
  "xAxis": {
    "type": "category",
    "data": ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
  },
  "yAxis": { "type": "value" },
  "series": [
    {
      "name": "访问量",
      "type": "line",
      "smooth": true,
      "areaStyle": {},
      "data": [120, 200, 150, 80, 70, 110, 130]
    }
  ]
}
```
md

旧站上这里是两张 ECharts 图。现在的站点在构建时把同样的数据画成静态 SVG;饼图没有对应的图表类型,写成 data 表格:

每周访问量
050100150200周二周四周六200 · 周二
文章分类占比
分类篇数占比
折腾4040%
开发3232%
笔记1818%
其他1010%

JSON 不合法时会回退成普通代码块,不会把构建搞挂。需要函数等 JSON 表达不了的配置时,也可以直接用 <ECharts> 组件传 JS 对象。图表跟随站点明暗主题,echarts 库按需懒加载,不进首屏包。

代码逻辑

短语法先在 remark 阶段变成组件

这两个正则负责识别行情图表写法。$AAPL 是 Mini Chart,::tv AAPL 是 Advanced Chart。

contentlayer.config.tsL251–253 · c067042 ↗
251const githubEmbedPattern = /^::github-(code|diff)\s+(.+)$/
252const tradingViewMiniPattern = /^\$([A-Za-z0-9._:-]+)$/
253const tradingViewAdvancedPattern = /^::(?:tv|tv-advanced|tradingview)\s+(.+)$/

Mini Chart 的转换很克制:只有整段内容就是一个 ticker 时才会替换,避免正文里随手提到 $AAPL 就插入一个大图表。

contentlayer.config.tsL288–295 · c067042 ↗
288function createTradingViewMiniNode(symbol: string): MdastNode | null {
289  if (!isTradingViewTicker(symbol)) {
290    return null
291  }
292
293  return createMdxFlowNode('TradingViewMiniChart', {
294    symbol: normalizeTradingViewSymbol(symbol),
295  })

Advanced Chart 多一步解析参数。比如 height=460、interval=60 会作为 JSX attribute 传给组件。

contentlayer.config.tsL298–313 · c067042 ↗
298function createTradingViewAdvancedNode(source: string): MdastNode | null {
299  const [rawSymbol, ...attrParts] = source.trim().split(/\s+/)
300
301  if (!rawSymbol || !isTradingViewTicker(rawSymbol)) {
302    return null
303  }
304
305  const attrs = parseEmbedAttributes(attrParts.join(' ')) as TradingViewAttrs
306
307  return createMdxFlowNode('TradingViewAdvancedChart', {
308    height: attrs.height,
309    interval: attrs.interval,
310    locale: attrs.locale,
311    symbol: normalizeTradingViewSymbol(rawSymbol),
312    timezone: attrs.timezone,
313  })

最后把插件挂进 Contentlayer 的 remark 链路里。这样短语法发生在 MDX 编译前,浏览器里不会再扫字符串。

contentlayer.config.tsL1063–1069 · c067042 ↗
1063export default makeSource({
1064  contentDirPath: 'data',
1065  documentTypes: [Blog, Authors],
1066  mdx: {
1067    cwd: process.cwd(),
1068    remarkPlugins: [remarkGfm, remarkTradingViewWidgets, remarkIconShortcodes, remarkGitHubEmbeds],
1069    rehypePlugins: [

TradingView 只在客户端加载

TradingView 官方 widget 需要插入外部 script,所以组件保持为 client component。渲染时先清空容器,再放入 widget 容器和配置 script;主题或参数变化时重新生成。

components/TradingViewWidgets.tsxL83–101 · c067042 ↗
83useEffect(() => {
84  const container = containerRef.current
85
86  if (!container) {
87    return
88  }
89
90  container.innerHTML = '<div class="tradingview-widget-container__widget"></div>'
91
92  const script = document.createElement('script')
93  script.async = true
94  script.src = scriptSrc
95  script.text = JSON.stringify(config)
96  container.appendChild(script)
97
98  return () => {
99    container.innerHTML = ''
100  }
101}, [config, scriptSrc])

股票代码会先规范化。AAPL 默认映射成 NASDAQ:AAPL,如果你写 NYSE:IBM 这种完整 symbol,就不会改动。

lib/tradingview.tsL4–20 · c067042 ↗
4export function normalizeTradingViewSymbol(value: string, defaultExchange = 'NASDAQ') {
5  const symbol = value.trim().replace(/^\$/, '').toUpperCase()
6
7  if (!symbol) {
8    return ''
9  }
10
11  if (explicitTradingViewSymbolPattern.test(symbol)) {
12    return symbol
13  }
14
15  if (plainTickerPattern.test(symbol)) {
16    return `${defaultExchange}:${symbol}`
17  }
18
19  return symbol
20}

代码块标题栏统一增强

代码块语言图标没有在每篇文章里手写,而是在 rehype 阶段统一补。它会先找到 Shiki 生成的 figure,再保证有一个标题栏。

contentlayer.config.tsL850–868 · c067042 ↗
850function enhanceCodeTitle(node: HastNode) {
851  if (
852    node.tagName !== 'figure' ||
853    node.properties?.['data-rehype-pretty-code-figure'] === undefined
854  ) {
855    return
856  }
857
858  const title = ensureCodeTitle(node)
859  const code = findDescendantElement(node, 'code')
860  const sourceUrl = typeof code?.data?.githubSourceUrl === 'string' ? code.data.githubSourceUrl : ''
861  const language = getCodeLanguage(code)
862  const titleText = getNodeText(title) || getLanguageName(language)
863
864  title.children = [createCodeTitleNode(titleText, language)]
865
866  if (sourceUrl) {
867    title.children.push(createCodeSourceLinkNode(sourceUrl, language))
868  }

标题栏左侧由语言图标和标题文本组成。没有显式 title 时,就回退到语言名,比如 TypeScript、Diff、Markdown。

contentlayer.config.tsL787–799 · c067042 ↗
787function createCodeTitleNode(titleText: string, language: string): HastNode {
788  return {
789    type: 'element',
790    tagName: 'span',
791    properties: { className: ['code-title-main'] },
792    children: [
793      createCodeLanguageIconNode(language),
794      {
795        type: 'element',
796        tagName: 'span',
797        properties: { className: ['code-title-text'] },
798        children: [{ type: 'text', value: titleText || getLanguageName(language) }],
799      },

视觉上 logo 直接来自 simple-icons 的品牌图标,按需 tree-shake,不会把首屏 JavaScript 变重;没有品牌图标的语言才回退到简短的文字标注。

css/prism.cssL11–17 · c067042 ↗
11figure[data-rehype-pretty-code-figure] .code-title-main {
12  @apply inline-flex min-w-0 items-center gap-2;
13}
14
15figure[data-rehype-pretty-code-figure] .code-language-icon {
16  @apply inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-[5px] border border-slate-300 bg-slate-100 px-1 font-sans text-[0.62rem] leading-none font-semibold text-slate-600 dark:border-white/15 dark:bg-white/10 dark:text-white/70;
17}

inline code 不再抢正文注意力

inline code 这次改成了低对比度、有边界的 token。它仍然能被识别为代码,但不会像按钮一样跳出来。

css/tailwind.cssL110–124 · c067042 ↗
110& :where(code):not(pre code) {
111  border: 1px solid color-mix(in oklab, var(--color-slate-300) 72%, transparent);
112  border-radius: 6px;
113  background: linear-gradient(
114    to bottom,
115    color-mix(in oklab, white 92%, var(--color-slate-100)),
116    color-mix(in oklab, var(--color-slate-100) 88%, white)
117  );
118  box-shadow: inset 0 -1px 0 color-mix(in oklab, var(--color-slate-300) 45%, transparent);
119  color: #4c4f69;
120  padding: 0.08rem 0.34rem;
121  font-size: 0.86em;
122  font-weight: 500;
123  line-height: 1.75;
124  word-break: break-word;

GitHub 代码和 diff 仍然走 Shiki

::github-code 和 ::github-diff 在构建时会拉取真实代码或 diff,再生成普通 code node。也就是说它们不需要特殊渲染器,最后仍然交给 Shiki 高亮。

contentlayer.config.tsL543–552 · c067042 ↗
543if (kind === 'code') {
544  const value = selectCodeLines(await getGitHubFile(attrs), attrs.lines)
545  const title =
546    attrs.title ||
547    `${normalizeGitHubRepo(attrs.repo)}:${attrs.path}${attrs.lines ? `#L${attrs.lines}` : ''}`
548
549  return createCodeNode(value, attrs.lang || 'text', title, {
550    showLineNumbers: true,
551    sourceUrl: getGitHubCodeUrl(attrs),
552  })

diff 的路径也一样,只是数据源换成 patch,语言固定成 diff。

contentlayer.config.tsL555–563 · c067042 ↗
555const value = selectCodeLines(await getGitHubDiff(attrs), attrs.lines)
556const title =
557  attrs.title ||
558  `${normalizeGitHubRepo(attrs.repo)}:${attrs.path || `${attrs.base || attrs.ref}...${attrs.head || ''}`}`
559
560return createCodeNode(value || 'No diff matched this query.', attrs.lang || 'diff', title, {
561  showLineNumbers: true,
562  sourceUrl: getGitHubDiffUrl(attrs),
563})

这也是为什么 GitHub 引用块能同时拥有:Shiki 颜色主题、语言图标、复制按钮、GitHub 跳转和 diff 背景。

现在写博客的心智模型

普通文章只管写 Markdown。需要图标时写 :icon-code:,需要行情时写 $AAPL 或 ::tv AAPL,需要数据图表时写 ```echarts 代码块,需要引用源码时写 ::github-code,需要引用改动时写 ::github-diff。

构建阶段会把这些短语法转换成稳定的 MDX 组件或 Shiki 代码块;运行时只负责交互和外部 widget 加载。这样写作语法短,页面输出也可控。