Compare commits
No commits in common. "f6b7ab4333037e03a776912453a0b55eb52c3bcc" and "ac04b23b597598ea42ad0479689c4fe2aea02daf" have entirely different histories.
f6b7ab4333
...
ac04b23b59
32
input.css
32
input.css
@ -405,36 +405,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-content li > input[type="checkbox"] {
|
.md-content li > input[type="checkbox"] {
|
||||||
/* appearance:none 全自定义:告别浏览器原生方框形状/边框差异。
|
|
||||||
对勾用内联 SVG background(<input> 是替换元素,吃不到 ::after),
|
|
||||||
颜色按主题烘焙——亮色中绿底配白对勾,暗色浅绿底配深对勾,对比最强。
|
|
||||||
文章内 checkbox 是 disabled 的纯展示态,故不加 focus/cursor 交互。 */
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 1.15rem;
|
width: 1rem;
|
||||||
height: 1.15rem;
|
height: 1rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: 1.5px solid var(--color-paper-border);
|
accent-color: var(--color-paper-accent);
|
||||||
border-radius: 4px;
|
|
||||||
background-color: transparent;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 0.75rem;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-content li > input[type="checkbox"]:checked {
|
|
||||||
background-color: var(--color-paper-accent);
|
|
||||||
border-color: var(--color-paper-accent);
|
|
||||||
/* 亮色:中绿 #40a02b 底 + 白对勾(对比 ≥ 4.5:1) */
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%20fill='none'%20stroke='%23ffffff'%20stroke-width='2.2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M3.5%208.5l3%203%206-6.5'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗色:浅绿 #a6e3a1 底上白对勾对比不足,改深色对勾(主题底色 #1e1e2e) */
|
|
||||||
.dark .md-content li > input[type="checkbox"]:checked {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%20fill='none'%20stroke='%231e1e2e'%20stroke-width='2.2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M3.5%208.5l3%203%206-6.5'/%3E%3C/svg%3E");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content hr {
|
.md-content hr {
|
||||||
@ -553,7 +529,7 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
font-size: 1rem;
|
font-size: 0.85em;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,5 @@
|
|||||||
// 把 mermaid 包成 IIFE 全局 bundle。
|
// 把 mermaid 默认导出包成 IIFE 全局 bundle。
|
||||||
//
|
// yggdrasil-core 的 mermaid.ts 通过动态 import('/mermaid/mermaid.js') 拿到 .default。
|
||||||
// 关键:vite IIFE + `export default X` 会产出 `window.MermaidRenderer.default = X`
|
|
||||||
// (多一层 .default)。为避免 mermaid.ts 取值时纠结这一层,这里把 mermaid 对象
|
|
||||||
// 直接赋给 window.MermaidRenderer 作为构建时副作用,使 window.MermaidRenderer
|
|
||||||
// 本身即 mermaid API(initialize/render)。
|
|
||||||
import mermaid from 'mermaid';
|
import mermaid from 'mermaid';
|
||||||
|
|
||||||
// 构建时副作用:把 mermaid 挂到全局。ES module 的顶层赋值会被 vite 保留。
|
|
||||||
;(globalThis as unknown as { MermaidRenderer: typeof mermaid }).MermaidRenderer = mermaid;
|
|
||||||
|
|
||||||
export default mermaid;
|
export default mermaid;
|
||||||
|
|||||||
@ -4,38 +4,15 @@
|
|||||||
*
|
*
|
||||||
* 现在用 window.scrollTo 手动扣除 sticky header 高度(不再依赖 scrollIntoView +
|
* 现在用 window.scrollTo 手动扣除 sticky header 高度(不再依赖 scrollIntoView +
|
||||||
* scroll-margin-top,因为浏览器原生 fragment-scroll 对后者的应用时序不稳定)。
|
* scroll-margin-top,因为浏览器原生 fragment-scroll 对后者的应用时序不稳定)。
|
||||||
*
|
|
||||||
* 布局稳定期(ResizeObserver)测试:钉住异步布局位移(mermaid 懒加载注入 SVG、
|
|
||||||
* 图片/字体加载撑高内容)后落点会重新校正,且用户主动滚动后停止校正。
|
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import './index';
|
import './index';
|
||||||
|
|
||||||
// --- ResizeObserver mock:happy-dom 不实现,手动提供回调钩子 ---
|
|
||||||
let resizeCallback:
|
|
||||||
| ((entries: { target: Element; contentRect: { width: number; height: number } }[]) => void)
|
|
||||||
| null = null;
|
|
||||||
const roDisconnect = vi.fn();
|
|
||||||
vi.stubGlobal(
|
|
||||||
'ResizeObserver',
|
|
||||||
class {
|
|
||||||
constructor(cb: typeof resizeCallback) {
|
|
||||||
resizeCallback = cb;
|
|
||||||
}
|
|
||||||
observe() {}
|
|
||||||
disconnect() {
|
|
||||||
roDisconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('scrollToHash', () => {
|
describe('scrollToHash', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
history.replaceState(null, '', '#');
|
history.replaceState(null, '', '#');
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
resizeCallback = null;
|
|
||||||
roDisconnect.mockClear();
|
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
@ -131,93 +108,4 @@ describe('scrollToHash', () => {
|
|||||||
expect(() => window.__scrollToHash()).not.toThrow();
|
expect(() => window.__scrollToHash()).not.toThrow();
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 布局稳定期(ResizeObserver) ----------
|
|
||||||
// 复现 bug 根因:mermaid 懒加载等异步位移源发生在两帧校正之后,撑高内容把标题
|
|
||||||
// 推离落点。稳定期监听内容容器 resize,位移后重新校正一次。
|
|
||||||
|
|
||||||
/** 构造一个带 .post-content 容器和带 id 标题的 DOM,用于稳定期测试。 */
|
|
||||||
function setupPostWithHeading(headingId: string): { root: HTMLElement; heading: HTMLElement } {
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.className = 'post-content';
|
|
||||||
const heading = document.createElement('h2');
|
|
||||||
heading.id = headingId;
|
|
||||||
heading.getBoundingClientRect = () =>
|
|
||||||
({
|
|
||||||
top: 1000,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 1050,
|
|
||||||
width: 800,
|
|
||||||
height: 50,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
toJSON() {},
|
|
||||||
}) as DOMRect;
|
|
||||||
root.appendChild(heading);
|
|
||||||
document.body.appendChild(root);
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true });
|
|
||||||
return { root, heading };
|
|
||||||
}
|
|
||||||
|
|
||||||
it('内容容器 resize 后再次校正落点(异步布局位移修正)', async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
||||||
setupPostWithHeading('mermaid-tu-biao');
|
|
||||||
history.replaceState(null, '', '#mermaid-tu-biao');
|
|
||||||
|
|
||||||
window.__scrollToHash();
|
|
||||||
// 推进两帧校正的 rAF(fake rAF 周期 ~16ms),不触发 2s 超时。
|
|
||||||
await vi.advanceTimersByTimeAsync(40);
|
|
||||||
spy.mockClear();
|
|
||||||
|
|
||||||
expect(resizeCallback).not.toBeNull();
|
|
||||||
// 模拟 mermaid 注入 SVG 撑高内容:触发一次 resize。
|
|
||||||
resizeCallback!([
|
|
||||||
{
|
|
||||||
target: document.querySelector('.post-content')!,
|
|
||||||
contentRect: { width: 800, height: 2000 },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
// rAF 合并的校正在下一帧执行。
|
|
||||||
await vi.advanceTimersByTimeAsync(20);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalled();
|
|
||||||
// 推进超时,让稳定器在本测试内自清理,避免泄漏到后续测试。
|
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户主动滚动(wheel)后停止校正,避免与用户交互打架', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
||||||
setupPostWithHeading('h2');
|
|
||||||
history.replaceState(null, '', '#h2');
|
|
||||||
|
|
||||||
window.__scrollToHash();
|
|
||||||
expect(roDisconnect).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// 用户主动滚动:派发 wheel,应触发 dispose(RO 断开、监听移除、超时清除)。
|
|
||||||
window.dispatchEvent(new WheelEvent('wheel'));
|
|
||||||
expect(roDisconnect).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// dispose 后 resize 不再触发新的稳定器:RO 已断开,回调不会再被调用。
|
|
||||||
expect(resizeCallback).not.toBeNull();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('重入安全:再次 scrollToHash 时旧稳定器被清理(不泄漏/不叠加)', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
||||||
setupPostWithHeading('h3');
|
|
||||||
history.replaceState(null, '', '#h3');
|
|
||||||
|
|
||||||
window.__scrollToHash();
|
|
||||||
expect(roDisconnect).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// 第二次调用(模拟主题切换触发 use_effect 重跑)。
|
|
||||||
window.__scrollToHash();
|
|
||||||
expect(roDisconnect).toHaveBeenCalled();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,11 +10,6 @@
|
|||||||
* 解法:用 requestAnimationFrame 延后两帧再做最终校正,确保我们的落点在原生
|
* 解法:用 requestAnimationFrame 延后两帧再做最终校正,确保我们的落点在原生
|
||||||
* scroll 之后生效(原生 scroll 通常在下一帧完成)。
|
* scroll 之后生效(原生 scroll 通常在下一帧完成)。
|
||||||
*
|
*
|
||||||
* 异步布局位移问题:两帧校正之后仍可能有异步内容撑高布局——mermaid 懒加载
|
|
||||||
* 注入 SVG、图片/字体加载等发生在两帧之后,把目标标题推离已校准的落点,导致
|
|
||||||
* 直接访问带 hash 的 URL 时标题停在屏幕中间。解法:两帧校正后开启一个 ResizeObserver
|
|
||||||
* 布局稳定期(见 stabilizeScrollOnResize),位移后重新校正,直到超时或用户主动滚动。
|
|
||||||
*
|
|
||||||
* CJK 编码要点:location.hash 返回百分号编码形式(%E4%B8%89-...),而标题 id
|
* CJK 编码要点:location.hash 返回百分号编码形式(%E4%B8%89-...),而标题 id
|
||||||
* 属性是原始字符(id="三-五-零法则")。必须先 decodeURIComponent 还原,getElementById
|
* 属性是原始字符(id="三-五-零法则")。必须先 decodeURIComponent 还原,getElementById
|
||||||
* 才能匹配。双 fallback(先解码、后原始)兼容两种情况。
|
* 才能匹配。双 fallback(先解码、后原始)兼容两种情况。
|
||||||
@ -22,75 +17,6 @@
|
|||||||
|
|
||||||
import { scrollToHeading } from './scroll-to-heading';
|
import { scrollToHeading } from './scroll-to-heading';
|
||||||
|
|
||||||
/** 布局稳定期时长:覆盖 mermaid bundle 加载+渲染、图片/字体加载等异步位移源。 */
|
|
||||||
const STABILIZE_WINDOW_MS = 2000;
|
|
||||||
|
|
||||||
/** 用户主动滚动的输入事件:收到任一即认为用户接管,停止自动校正。 */
|
|
||||||
const USER_INPUT_EVENTS: Array<keyof WindowEventMap> = ['wheel', 'touchmove', 'keydown'];
|
|
||||||
|
|
||||||
/** 当前活跃的稳定器;重入时先 dispose 上一次,避免泄漏/叠加。 */
|
|
||||||
let activeStabilizer: (() => void) | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 布局稳定期:在 STABILIZE_WINDOW_MS 内监听内容容器尺寸变化,位移后用 rAF 合并
|
|
||||||
* 并重新校正落点。用户主动滚动或超时后自动停止。
|
|
||||||
*
|
|
||||||
* 为什么观察容器而非标题本身:标题自身尺寸不变(ResizeObserver 不触发),位移来自
|
|
||||||
* 它上方的 mermaid/图片撑高。观察 .post-content 容器(标题最近的正文祖先)能覆盖
|
|
||||||
* 所有可能撑高的内容,且与具体位移源(mermaid/图片/字体)解耦。
|
|
||||||
*/
|
|
||||||
function stabilizeScrollOnResize(el: Element): void {
|
|
||||||
// 重入安全:dispose 上一次活跃的稳定器。
|
|
||||||
activeStabilizer?.();
|
|
||||||
activeStabilizer = null;
|
|
||||||
|
|
||||||
if (typeof ResizeObserver === 'undefined') return;
|
|
||||||
|
|
||||||
const target = el.closest('.post-content') ?? document.body;
|
|
||||||
let rafId = 0;
|
|
||||||
let disposed = false;
|
|
||||||
|
|
||||||
const dispose = () => {
|
|
||||||
if (disposed) return;
|
|
||||||
disposed = true;
|
|
||||||
ro.disconnect();
|
|
||||||
window.clearTimeout(timerId);
|
|
||||||
for (const evt of USER_INPUT_EVENTS) {
|
|
||||||
window.removeEventListener(evt, onUserInput, { capture: true } as EventListenerOptions);
|
|
||||||
}
|
|
||||||
if (rafId) window.cancelAnimationFrame(rafId);
|
|
||||||
if (activeStabilizer === dispose) activeStabilizer = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// rAF 合并:同帧多次 resize 只校正一次,避免抖动。
|
|
||||||
const scheduleCorrect = () => {
|
|
||||||
if (disposed) return;
|
|
||||||
if (rafId) return;
|
|
||||||
rafId = window.requestAnimationFrame(() => {
|
|
||||||
rafId = 0;
|
|
||||||
if (!disposed) scrollToHeading(el, false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 用户主动滚动即停止校正:不与用户交互打架。
|
|
||||||
// 用输入事件而非 scroll 事件——window.scrollTo 本身会触发 scroll,无法区分。
|
|
||||||
const onUserInput = () => dispose();
|
|
||||||
for (const evt of USER_INPUT_EVENTS) {
|
|
||||||
window.addEventListener(evt, onUserInput, {
|
|
||||||
capture: true,
|
|
||||||
passive: true,
|
|
||||||
} as AddEventListenerOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(scheduleCorrect);
|
|
||||||
ro.observe(target);
|
|
||||||
|
|
||||||
// 超时兜底:无论如何 STABILIZE_WINDOW_MS 后停止,防止长期占用。
|
|
||||||
const timerId = window.setTimeout(dispose, STABILIZE_WINDOW_MS);
|
|
||||||
|
|
||||||
activeStabilizer = dispose;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function scrollToHash(): void {
|
export function scrollToHash(): void {
|
||||||
const hash = window.location.hash.slice(1); // 去掉前导 #
|
const hash = window.location.hash.slice(1); // 去掉前导 #
|
||||||
if (!hash) return;
|
if (!hash) return;
|
||||||
@ -108,8 +34,4 @@ export function scrollToHash(): void {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => scrollToHeading(el, false));
|
requestAnimationFrame(() => scrollToHeading(el, false));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 布局稳定期:覆盖两帧之后的异步位移(mermaid 懒加载、图片/字体加载等),
|
|
||||||
// 位移后重新校正。用户主动滚动或超时后自动停止。
|
|
||||||
stabilizeScrollOnResize(el);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Mermaid 流程图懒加载渲染。
|
* Mermaid 流程图懒加载渲染。
|
||||||
*
|
*
|
||||||
* 扫描 `pre > code.language-mermaid` 代码块,在进入视口时动态加载独立 IIFE bundle
|
* 扫描 `pre > code.language-mermaid` 代码块,在进入视口时动态 import 独立 bundle
|
||||||
* `public/mermaid/mermaid.js`(~1MB,只在有图且可见时加载,不影响无图文章首屏),
|
* `public/mermaid/mermaid.js`(~1MB,只在有图且可见时加载,不影响无图文章首屏),
|
||||||
* 把 mermaid 源码渲染成 SVG 注入到父 <pre>。
|
* 把 mermaid 源码渲染成 SVG 注入到父 <pre>。
|
||||||
*
|
*
|
||||||
* bundle 是 IIFE 格式(与项目其他前端库一致),挂全局变量 `window.MermaidRenderer`。
|
|
||||||
* 故不能用 ES module 的 `import().default`(IIFE 无 export)——改用动态注入
|
|
||||||
* `<script src="/mermaid/mermaid.js">` 标签,加载后从 window 取 MermaidRenderer。
|
|
||||||
*
|
|
||||||
* 范式照搬 post-content.ts:querySelectorAll + 幂等守卫 + 注入 DOM。
|
* 范式照搬 post-content.ts:querySelectorAll + 幂等守卫 + 注入 DOM。
|
||||||
* mermaid 无官方 SSR 支持,纯客户端渲染;服务端 markdown.rs 只产出带
|
* mermaid 无官方 SSR 支持,纯客户端渲染;服务端 markdown.rs 只产出带
|
||||||
* `language-mermaid` class 的普通代码块,不挂 data 属性。
|
* `language-mermaid` class 的普通代码块,不挂 data 属性。
|
||||||
@ -21,40 +17,21 @@ type MermaidApi = {
|
|||||||
render: (id: string, text: string) => Promise<{ svg: string }>;
|
render: (id: string, text: string) => Promise<{ svg: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
MermaidRenderer?: MermaidApi;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mermaidPromise: Promise<MermaidApi> | null = null;
|
let mermaidPromise: Promise<MermaidApi> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态加载 mermaid 独立 IIFE bundle 的底层函数(可注入以便测试)。
|
* 动态加载 mermaid 独立 bundle 的底层函数(可注入以便测试)。
|
||||||
*
|
*
|
||||||
* IIFE bundle 挂在 `window.MermaidRenderer`(非 ES module export),故用动态注入
|
* 用绝对路径 '/mermaid/mermaid.js' 动态 import:Vite 无法静态分析此字面量,
|
||||||
* `<script>` 标签的方式加载,标签 onload 后从 window 取。测试时可通过
|
* 故加 @vite-ignore 避免构建时报错,且该路径无类型声明需用函数间接构造 import
|
||||||
* [`_resetMermaidLoader`] 注入 mock。
|
* 字面量以绕过 tsc 的模块解析(TS2307)。bundle 加载后 default export 即 mermaid API。
|
||||||
|
* 测试时可通过重赋值 `loadMermaidBundle` 替换为 mock。
|
||||||
*/
|
*/
|
||||||
export let loadMermaidBundle: () => Promise<MermaidApi> = () =>
|
export let loadMermaidBundle: () => Promise<MermaidApi> = async () => {
|
||||||
new Promise((resolve, reject) => {
|
const url = '/mermaid/mermaid.js';
|
||||||
// 已加载(同页多次调用 / SPA 导航)直接复用。
|
const mod = (await import(/* @vite-ignore */ url)) as { default: MermaidApi };
|
||||||
if (window.MermaidRenderer) {
|
return mod.default;
|
||||||
resolve(window.MermaidRenderer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = '/mermaid/mermaid.js';
|
|
||||||
script.onload = () => {
|
|
||||||
if (window.MermaidRenderer) {
|
|
||||||
resolve(window.MermaidRenderer);
|
|
||||||
} else {
|
|
||||||
reject(new Error('mermaid bundle loaded but window.MermaidRenderer undefined'));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
script.onerror = () => reject(new Error('failed to load /mermaid/mermaid.js'));
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 重置加载函数(测试用,重新注入 mock 后必须重置 mermaidPromise 缓存)。 */
|
/** 重置加载函数(测试用,重新注入 mock 后必须重置 mermaidPromise 缓存)。 */
|
||||||
export function _resetMermaidLoader(loader?: () => Promise<MermaidApi>): void {
|
export function _resetMermaidLoader(loader?: () => Promise<MermaidApi>): void {
|
||||||
|
|||||||
@ -183,21 +183,6 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
code_buffer.push_str(&text);
|
code_buffer.push_str(&text);
|
||||||
}
|
}
|
||||||
Event::End(TagEnd::CodeBlock) => {
|
Event::End(TagEnd::CodeBlock) => {
|
||||||
// mermaid 代码块:前端 yggdrasil-core 扫描 language-mermaid 渲染成 SVG,
|
|
||||||
// 源码不应被 syntect 高亮(无语法定义,且会包 <span> 污染 textContent 提取)。
|
|
||||||
// 直接输出转义后的纯源码,前端 textContent 无损拿到原始 mermaid 文本。
|
|
||||||
let is_mermaid = code_lang
|
|
||||||
.as_deref()
|
|
||||||
.map(|l| l.split_whitespace().next() == Some("mermaid"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
if is_mermaid {
|
|
||||||
let escaped = crate::utils::html::escape_html(&code_buffer);
|
|
||||||
html.push_str(r#"<pre><code class="language-mermaid">"#);
|
|
||||||
html.push_str(&escaped);
|
|
||||||
html.push_str("</code></pre>");
|
|
||||||
in_codeblock = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 使用 syntect 对代码块进行服务端语法高亮。
|
// 使用 syntect 对代码块进行服务端语法高亮。
|
||||||
let highlighted =
|
let highlighted =
|
||||||
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
|
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
|
||||||
@ -922,24 +907,4 @@ console.log(1)
|
|||||||
result.html
|
result.html
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_markdown_mermaid_block_not_highlighted() {
|
|
||||||
// mermaid 块跳过 syntect 高亮:源码应是转义纯文本,不被 <span> 包裹。
|
|
||||||
// 前端 mermaid.ts 用 textContent 无损提取渲染成 SVG。
|
|
||||||
let result = render_markdown_enhanced("```mermaid\ngraph LR\n A --> B\n```");
|
|
||||||
assert!(
|
|
||||||
result.html.contains(r#"class="language-mermaid""#),
|
|
||||||
"应保留 language-mermaid class, got: {}",
|
|
||||||
result.html
|
|
||||||
);
|
|
||||||
// 不应被 syntect 高亮(无 text plain span)。
|
|
||||||
assert!(
|
|
||||||
!result.html.contains("text plain"),
|
|
||||||
"mermaid 源码不应被 syntect 高亮, got: {}",
|
|
||||||
result.html
|
|
||||||
);
|
|
||||||
// 源码内容保留(HTML 转义后)。
|
|
||||||
assert!(result.html.contains("graph LR"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user