这篇写于旧博客(Next.js + MDX)时期。迁移到现在的静态站后,这些功能都在构建时完成:TradingView 变成行情卡片,点「实时图表」才加载实时行情;ECharts 变成静态 SVG 图表,悬停显示数值,源码引用是带行号的固定 commit 片段,语言图标换成了代码块角落的语言名。
这次主要把博客的写作体验继续往前推了一步:代码块更容易扫读,代码引用能直接指向 GitHub,金融类内容可以用很短的语法插入 TradingView 图表。
先看新功能
代码块有语言图标
代码块右上角一直浮着语言 logo 和复制按钮,没有标题栏、没有徽章边框,不占额外空间。写了 title 或者代码来自 GitHub 时,代码上方只会多一行小字路径,GitHub 跳转入口收进右上角的一个小图标里。
const feature = '语言图标'
const theme = 'catppuccin'
console.log(feature, theme)tsinline 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 链接的代码块:
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渲染效果:
@@ -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.Pmd旧站上这两行会各变成一张 Mini Chart。现在的静态站不加载 TradingView 脚本,改成一张链到 TradingView 的行情卡片:
Advanced Chart 也有短语法
写作时输入:
::tv AAPL interval=60 height=460md旧站上这里是一张可交互的 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 表格:
| 分类 | 篇数 | 占比 |
|---|---|---|
| 折腾 | 40 | 40% |
| 开发 | 32 | 32% |
| 笔记 | 18 | 18% |
| 其他 | 10 | 10% |
JSON 不合法时会回退成普通代码块,不会把构建搞挂。需要函数等 JSON 表达不了的配置时,也可以直接用 <ECharts> 组件传 JS 对象。图表跟随站点明暗主题,echarts 库按需懒加载,不进首屏包。
代码逻辑
短语法先在 remark 阶段变成组件
这两个正则负责识别行情图表写法。$AAPL 是 Mini Chart,::tv AAPL 是 Advanced Chart。
251const githubEmbedPattern = /^::github-(code|diff)\s+(.+)$/
252const tradingViewMiniPattern = /^\$([A-Za-z0-9._:-]+)$/
253const tradingViewAdvancedPattern = /^::(?:tv|tv-advanced|tradingview)\s+(.+)$/Mini Chart 的转换很克制:只有整段内容就是一个 ticker 时才会替换,避免正文里随手提到 $AAPL 就插入一个大图表。
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 传给组件。
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 编译前,浏览器里不会再扫字符串。
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;主题或参数变化时重新生成。
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,就不会改动。
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,再保证有一个标题栏。
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。
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 变重;没有品牌图标的语言才回退到简短的文字标注。
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。它仍然能被识别为代码,但不会像按钮一样跳出来。
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 高亮。
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。
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 加载。这样写作语法短,页面输出也可控。