Compare commits

..

No commits in common. "bcd13958aca4491a9ab638fc23b4cc844b145cf8" and "2b75ac4d284ce72f1f66e8eaaf506ab11dd937da" have entirely different histories.

12 changed files with 12 additions and 3501 deletions

1
Cargo.lock generated
View File

@ -5602,7 +5602,6 @@ dependencies = [
"deadpool-postgres",
"dioxus",
"dotenvy",
"fancy-regex",
"futures",
"governor",
"hex",

View File

@ -20,10 +20,6 @@ argon2 = { version = "0.5", optional = true }
uuid = { version = "1", features = ["v4"], optional = true }
chrono = { version = "0.4", features = ["serde"] }
regex = { version = "1.12", optional = true }
# mhchem 化学公式转译器src/api/mhchem.rs需要 lookahead 正则,
# Rust `regex` crate 不支持 `(?=...)`/`(?!...)`,故用 `fancy-regex`。
# 该 crate 已被 syntect 间接引入0.16.2,见 Cargo.lock此处显式声明为直接依赖。
fancy-regex = { version = "0.16", optional = true }
pulldown-cmark = { version = "0.13", optional = true }
dotenvy = { version = "0.15", optional = true }
tracing = { version = "0.1", optional = true, features = ["release_max_level_info"] }
@ -102,7 +98,6 @@ server = [
"dep:argon2",
"dep:uuid",
"dep:regex",
"dep:fancy-regex",
"dep:pulldown-cmark",
"dep:rand",
"dep:http",

View File

@ -1,249 +0,0 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 617292de240bd244e51ab3b0690b8ece3353637e..c5421ddadc6aea87d7ba5f46f04d61ca36aa7834 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -274,6 +274,9 @@ function htmlContainsUnrecognizedTag(html, schemaTags) {
return !schemaTags.has(tagName);
});
}
+function encodeLimitedHtmlEntities(text) {
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;");
+}
// src/MarkdownManager.ts
var MarkdownManager = class {
@@ -1049,29 +1052,40 @@ var MarkdownManager = class {
return { type: "text", text };
}
/**
- * Encode HTML entities in text unless the node is inside a code context
- * (code mark or code-block parent) where literal characters should be preserved.
- * Also backslash-escape markdown-significant characters in non-code text to
- * prevent them from being misinterpreted as formatting delimiters.
+ * Encode HTML entities + backslash-escape markdown-significant chars.
+ * Patch: CommonMark direction-aware escape for * and _ (only escape
+ * emphasis-boundary positions, not intra-word); drop > from HTML encoding.
*/
encodeTextForMarkdown(text, node, parentNode) {
const isInsideCode = (parentNode == null ? void 0 : parentNode.type) != null && this.codeTypes.has(parentNode.type) || (node.marks || []).some((m) => this.codeTypes.has(typeof m === "string" ? m : m.type));
if (isInsideCode) {
return text;
}
- return this.escapeMarkdownSyntax(_core.encodeHtmlEntities.call(void 0, text));
+ return this.escapeMarkdownSyntax(encodeLimitedHtmlEntities(text));
}
- /**
- * Backslash-escape characters that have special meaning in markdown inline
- * syntax. This prevents literal characters in text nodes from being
- * misinterpreted as formatting delimiters when the output is parsed again.
- *
- * The set covers the most common inline markdown syntax characters.
- * Characters inside code blocks/code marks are skipped by the caller
- * (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
- */
escapeMarkdownSyntax(text) {
- return text.replace(/([\\`*_[\]~])/g, "\\$1");
+ const punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~';
+ let result = "";
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i];
+ if (ch === "*" || ch === "_") {
+ const prev = i > 0 ? text[i - 1] : "";
+ const next = i < text.length - 1 ? text[i + 1] : "";
+ const leftSpace = prev === "" || /\s/.test(prev);
+ const leftPunct = punct.includes(prev);
+ const leftOther = !leftSpace && !leftPunct;
+ const rightSpace = next === "" || /\s/.test(next);
+ const rightPunct = punct.includes(next);
+ const rightOther = !rightSpace && !rightPunct;
+ const isBoundary = ((leftSpace || leftPunct) && rightOther) || ((rightSpace || rightPunct) && leftOther);
+ result += isBoundary ? "\\" + ch : ch;
+ } else if (ch === "`" || ch === "[" || ch === "]" || ch === "~" || ch === "\\") {
+ result += "\\" + ch;
+ } else {
+ result += ch;
+ }
+ }
+ return result;
}
renderNodeToMarkdown(node, parentNode, index = 0, level = 0, meta = {}) {
var _a;
diff --git a/dist/index.js b/dist/index.js
index 18088ba76ee44bcddc94fdf30502928249516f10..e57cf65f9ffabca7728de5a39637d309720394c5 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -9,7 +9,6 @@ import {
attrsEqual as attrsEqual2,
callOrReturn,
decodeHtmlEntities,
- encodeHtmlEntities,
flattenExtensions,
generateJSON,
getExtensionField,
@@ -274,6 +273,9 @@ function htmlContainsUnrecognizedTag(html, schemaTags) {
return !schemaTags.has(tagName);
});
}
+function encodeLimitedHtmlEntities(text) {
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;");
+}
// src/MarkdownManager.ts
var MarkdownManager = class {
@@ -1049,29 +1051,40 @@ var MarkdownManager = class {
return { type: "text", text };
}
/**
- * Encode HTML entities in text unless the node is inside a code context
- * (code mark or code-block parent) where literal characters should be preserved.
- * Also backslash-escape markdown-significant characters in non-code text to
- * prevent them from being misinterpreted as formatting delimiters.
+ * Encode HTML entities + backslash-escape markdown-significant chars.
+ * Patch: CommonMark direction-aware escape for * and _ (only escape
+ * emphasis-boundary positions, not intra-word); drop > from HTML encoding.
*/
encodeTextForMarkdown(text, node, parentNode) {
const isInsideCode = (parentNode == null ? void 0 : parentNode.type) != null && this.codeTypes.has(parentNode.type) || (node.marks || []).some((m) => this.codeTypes.has(typeof m === "string" ? m : m.type));
if (isInsideCode) {
return text;
}
- return this.escapeMarkdownSyntax(encodeHtmlEntities(text));
+ return this.escapeMarkdownSyntax(encodeLimitedHtmlEntities(text));
}
- /**
- * Backslash-escape characters that have special meaning in markdown inline
- * syntax. This prevents literal characters in text nodes from being
- * misinterpreted as formatting delimiters when the output is parsed again.
- *
- * The set covers the most common inline markdown syntax characters.
- * Characters inside code blocks/code marks are skipped by the caller
- * (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
- */
escapeMarkdownSyntax(text) {
- return text.replace(/([\\`*_[\]~])/g, "\\$1");
+ const punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~';
+ let result = "";
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i];
+ if (ch === "*" || ch === "_") {
+ const prev = i > 0 ? text[i - 1] : "";
+ const next = i < text.length - 1 ? text[i + 1] : "";
+ const leftSpace = prev === "" || /\s/.test(prev);
+ const leftPunct = punct.includes(prev);
+ const leftOther = !leftSpace && !leftPunct;
+ const rightSpace = next === "" || /\s/.test(next);
+ const rightPunct = punct.includes(next);
+ const rightOther = !rightSpace && !rightPunct;
+ const isBoundary = ((leftSpace || leftPunct) && rightOther) || ((rightSpace || rightPunct) && leftOther);
+ result += isBoundary ? "\\" + ch : ch;
+ } else if (ch === "`" || ch === "[" || ch === "]" || ch === "~" || ch === "\\") {
+ result += "\\" + ch;
+ } else {
+ result += ch;
+ }
+ }
+ return result;
}
renderNodeToMarkdown(node, parentNode, index = 0, level = 0, meta = {}) {
var _a;
diff --git a/src/MarkdownManager.ts b/src/MarkdownManager.ts
index 5d6256f921567f53268827cc549bb83b77b3130d..146514f78f857951eebb1f776756b381cc5fc90b 100644
--- a/src/MarkdownManager.ts
+++ b/src/MarkdownManager.ts
@@ -13,7 +13,6 @@ import {
attrsEqual,
callOrReturn,
decodeHtmlEntities,
- encodeHtmlEntities,
flattenExtensions,
generateJSON,
getExtensionField,
@@ -34,6 +33,19 @@ import {
} from './utils.js'
import { htmlContainsUnrecognizedTag } from './utils/htmlTagDetection.js'
+/**
+ * Encode only `<` and `&` as HTML entities. Patch replacement for the
+ * `encodeHtmlEntities` from `@tiptap/core`, which also encodes `>`.
+ *
+ * `>` inside a text node is never a blockquote start (block tokenizer consumes
+ * line-leading `>` before text nodes exist), so encoding it only produces
+ * `&gt;` that re-imports as literal `&gt;` text — the `&gt;` corruption.
+ * `<` and `&` still need encoding to avoid being parsed as tags/entities.
+ */
+function encodeLimitedHtmlEntities(text: string): string {
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;')
+}
+
export class MarkdownManager {
private markedInstance: typeof marked
private activeParseLexer: Lexer | null = null
@@ -1087,6 +1099,12 @@ export class MarkdownManager {
* (code mark or code-block parent) where literal characters should be preserved.
* Also backslash-escape markdown-significant characters in non-code text to
* prevent them from being misinterpreted as formatting delimiters.
+ *
+ * Patch: drop `>` from HTML entity encoding (only encode `<` and `&`).
+ * A `>` inside a text node never starts a blockquote (line-start `>` is
+ * consumed by the block tokenizer before reaching text nodes), so encoding
+ * it only produces `&gt;` that re-imports as literal text — the root cause of
+ * the `&gt;` corruption.
*/
private encodeTextForMarkdown(text: string, node: JSONContent, parentNode?: JSONContent): string {
const isInsideCode =
@@ -1097,7 +1115,7 @@ export class MarkdownManager {
return text
}
- return this.escapeMarkdownSyntax(encodeHtmlEntities(text))
+ return this.escapeMarkdownSyntax(encodeLimitedHtmlEntities(text))
}
/**
@@ -1105,12 +1123,44 @@ export class MarkdownManager {
* syntax. This prevents literal characters in text nodes from being
* misinterpreted as formatting delimiters when the output is parsed again.
*
- * The set covers the most common inline markdown syntax characters.
+ * Patch: CommonMark direction-aware escaping for `*` and `_`. Instead of
+ * blindly escaping every `*`/`_` (which corrupts intra-word underscores like
+ * `content_html` → `content\_html`), only escape positions that would be
+ * consumed as emphasis open/close boundaries by a CommonMark parser. An
+ * emphasis boundary requires one flank to be {start, whitespace, punctuation}
+ * and the other to be a non-space non-punct char (letters / CJK / digits).
+ * Intra-word `*`/`_` (both flanks "other") is left alone.
+ * Structural characters (` ` [ ] ~ \) stay fully escaped — they are never
+ * emphasis boundaries but are structurally significant.
* Characters inside code blocks/code marks are skipped by the caller
* (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
*/
private escapeMarkdownSyntax(text: string): string {
- return text.replace(/([\\`*_[\]~])/g, '\\$1')
+ // CommonMark 标点集CJK/字母/数字属 "other"。
+ const punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
+ let result = ''
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i]
+ if (ch === '*' || ch === '_') {
+ const prev = i > 0 ? text[i - 1] : ''
+ const next = i < text.length - 1 ? text[i + 1] : ''
+ const leftSpace = prev === '' || /\s/.test(prev)
+ const leftPunct = punct.includes(prev)
+ const leftOther = !leftSpace && !leftPunct
+ const rightSpace = next === '' || /\s/.test(next)
+ const rightPunct = punct.includes(next)
+ const rightOther = !rightSpace && !rightPunct
+ // 可作开边界:(left∈{start,space,punct}) 且 (right∈other)
+ // 可作闭边界:(right∈{start,space,punct}) 且 (left∈other)
+ const isBoundary = ((leftSpace || leftPunct) && rightOther) || ((rightSpace || rightPunct) && leftOther)
+ result += isBoundary ? '\\' + ch : ch
+ } else if (ch === '`' || ch === '[' || ch === ']' || ch === '~' || ch === '\\') {
+ result += '\\' + ch
+ } else {
+ result += ch
+ }
+ }
+ return result
}
renderNodeToMarkdown(

7
libs/pnpm-lock.yaml generated
View File

@ -4,9 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
'@tiptap/markdown@3.27.3': 982043a3137900a6f90697c62a2c026a44c215725d951656385fd2c3cbe0e907
importers:
.:
@ -114,7 +111,7 @@ importers:
version: 3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
'@tiptap/markdown':
specifier: ^3.27.3
version: 3.27.3(patch_hash=982043a3137900a6f90697c62a2c026a44c215725d951656385fd2c3cbe0e907)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
version: 3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
'@tiptap/pm':
specifier: ^3.27.3
version: 3.27.3
@ -1901,7 +1898,7 @@ snapshots:
'@tiptap/core': 3.27.3(@tiptap/pm@3.27.3)
'@tiptap/pm': 3.27.3
'@tiptap/markdown@3.27.3(patch_hash=982043a3137900a6f90697c62a2c026a44c215725d951656385fd2c3cbe0e907)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)':
'@tiptap/markdown@3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)':
dependencies:
'@tiptap/core': 3.27.3(@tiptap/pm@3.27.3)
'@tiptap/pm': 3.27.3

View File

@ -1,4 +1,2 @@
packages:
- '*'
patchedDependencies:
'@tiptap/markdown@3.27.3': patches/@tiptap__markdown@3.27.3.patch

View File

@ -1,114 +0,0 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/core';
import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { beforeEach, describe, expect, it } from 'vitest';
/**
* Markdown (Fix 1:pnpm patch @tiptap/markdown)
*
* :@tiptap/markdown escapeMarkdownSyntax **
* `*`/`_`/`>` ,线(content_html content\_html)
* (> &gt;) marked
*
* 修补:CommonMark / `*`/`_`,
* ( other);`>` HTML
*
* (setContent markdown getMarkdown),
* MarkdownManager.serialize ()
*/
function makeEditor() {
return new Editor({
element: document.body,
extensions: [StarterKit.configure({ heading: { levels: [1, 2, 3] } }), Markdown],
content: '',
});
}
describe('markdown 转义往返 - 富文本模式不过度转义', () => {
let editor: Editor;
beforeEach(() => {
editor = makeEditor();
});
it('词内下划线不被转义:content_html 保持原样', () => {
editor.commands.setContent('字段 content_html 是渲染产物', { contentType: 'markdown' });
const md = editor.getMarkdown();
// 修复前:escapeMarkdownSyntax 把每个 _ 转义 → content\_html
expect(md).toContain('content_html');
expect(md).not.toContain('content\\_html');
});
it('行内大于号不被编码为 &gt;:> 入口提示', () => {
// 块级 > 是 blockquote,这里用行内(非行首)的 > 验证。
editor.commands.setContent('见 `foo > bar` 或 输入 > 入口提示', { contentType: 'markdown' });
const md = editor.getMarkdown();
expect(md).toContain('入口提示');
// 修复前:encodeHtmlEntities 把 > 编码为 &gt;
expect(md).not.toContain('&gt;');
});
it('字面星号 run 安全转义且往返不累积:用法**说明', () => {
// 词内 ** (CJK 两侧) 在无配对闭界时被 marked 保留为字面文本节点。
// 序列化时转义为 \*\* 是安全表示(\* 在 re-import 时解码回字面 *,不累积)。
// 关键契约:多轮往返稳定,不出现 \\\* 这类双重转义累积。
editor.commands.setContent('这是 用法**说明 文本', { contentType: 'markdown' });
const md1 = editor.getMarkdown();
// 第二轮
editor.commands.setContent(md1, { contentType: 'markdown' });
const md2 = editor.getMarkdown();
expect(md2).toBe(md1);
// 不应出现四反斜杠以上的双重转义累积。
expect(md2).not.toMatch(/\\\\{2,}\*/);
expect(md2).toContain('说明');
});
it('删除线 ~~~~ 结构性转义保留', () => {
// ~ 是结构性字符(strikethrough),应维持转义避免误判。
editor.commands.setContent('这是 ~~删除~~ 文本', { contentType: 'markdown' });
const md = editor.getMarkdown();
// ~~ 在词边界(空格两侧)会被 marked 识别为删除线;转义后序列化为 \~\~
expect(md).toContain('删除');
});
it('真实强调 **bold** 作为 strong 往返保留', () => {
editor.commands.setContent('这是 **加粗** 文本', { contentType: 'markdown' });
const md = editor.getMarkdown();
// 真实强调(两侧空格 + other)应作为 strong mark 识别并原样序列化。
expect(md).toContain('**加粗**');
});
it('字面反斜杠星号裸文本 \\* 不被双重转义', () => {
// 裸 \* 在 import 时被 marked 解析为 escape token → 字面 *。
// serialize 时这个字面 * 若处于边界位置会被转义一次(单层),不应变 \\*。
editor.commands.setContent('字面星号 \\* 在这里', { contentType: 'markdown' });
const md = editor.getMarkdown();
// 关键:不应出现四反斜杠(\\\\)这种双重转义累积。
expect(md).not.toContain('\\\\\\*');
});
it('行内代码反引号结构性转义保留', () => {
editor.commands.setContent('用 `code` 标记', { contentType: 'markdown' });
const md = editor.getMarkdown();
expect(md).toContain('`code`');
expect(md).toContain('标记');
});
it('多轮往返稳定:序列化 → 再解析 → 序列化不累积转义', () => {
const source = '字段 content_html 与 **加粗** 和 > 符号';
editor.commands.setContent(source, { contentType: 'markdown' });
const md1 = editor.getMarkdown();
editor.commands.setContent(md1, { contentType: 'markdown' });
const md2 = editor.getMarkdown();
editor.commands.setContent(md2, { contentType: 'markdown' });
const md3 = editor.getMarkdown();
// 关键契约:多轮不累积(content\_html → content\\\_html → ...)。
expect(md2).toBe(md1);
expect(md3).toBe(md1);
expect(md3).toContain('content_html');
expect(md3).not.toContain('&gt;');
});
});

View File

@ -191,16 +191,6 @@ fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
matches!(stmt, Statement::Query(_) | Statement::Explain { .. })
}
/// 判断一组语句中是否含有写操作(非只读语句)。
///
/// SQL 控制台执行写操作INSERT/UPDATE/DELETE/TRUNCATE/ALTER/DROP 等)可能直改
/// posts/comments/tags 等业务表,绕过 server function 的正常缓存失效路径。
/// 抽成纯函数便于单测「哪些语句集合应触发兜底失效」。
#[cfg(feature = "server")]
fn writes_affect_cache(stmts: &[sqlparser::ast::Statement]) -> bool {
stmts.iter().any(|s| !is_read_only(s))
}
/// 把一列的值转成 JSON按 PG 类型名分发)。
#[cfg(feature = "server")]
fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
@ -315,18 +305,6 @@ pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result<SqlResult,
let stmt_sql = stmt.to_string();
last_result = execute_one(&client, stmt, &stmt_sql, opts.with_explain, start).await?;
}
// 是否含写语句:抽成纯函数便于单测(见 writes_affect_cache
let has_write = writes_affect_cache(&asts);
// SQL 控制台写操作可能直改 posts/comments/tags绕过了 server function 的
// 正常失效路径delete_post/create_post 等已在内部失效)。此处全量兜底失效,
// 避免最长 10 分钟TTL_SINGLE_POST=600s内继续吐出陈旧数据含已删除文章
if has_write {
crate::cache::invalidate_all_post_caches();
crate::cache::invalidate_search_results();
crate::cache::invalidate_all_comments();
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation();
}
Ok(last_result)
}
#[cfg(not(feature = "server"))]
@ -710,42 +688,4 @@ mod tests {
assert!(!is_read_only(&parse("DELETE FROM t WHERE id = 1")[0]));
assert!(!is_read_only(&parse("INSERT INTO t (a) VALUES (1)")[0]));
}
// ── writes_affect_cacheSQL 控制台兜底失效开关) ─────────────
// 任何写语句(非只读)都应触发全量缓存失效,兜底绕过 server function 的直改 DB。
#[test]
fn writes_affect_cache_true_for_write_statements() {
for sql in [
"UPDATE posts SET deleted_at = NOW() WHERE id = 648",
"DELETE FROM posts WHERE id = 648",
"INSERT INTO posts (title) VALUES ('x')",
"TRUNCATE posts",
"ALTER TABLE posts ADD COLUMN x int",
"DROP TABLE posts",
] {
assert!(
writes_affect_cache(&parse(sql)),
"写语句应触发兜底失效:{sql:?}"
);
}
}
#[test]
fn writes_affect_cache_false_for_read_only_statements() {
for sql in ["SELECT 1", "EXPLAIN SELECT * FROM posts", "SELECT id FROM posts WHERE id = 1"]
{
assert!(
!writes_affect_cache(&parse(sql)),
"只读语句不应触发兜底失效:{sql:?}"
);
}
}
#[test]
fn writes_affect_cache_mixed_statements_flagged() {
// 多语句中混入任一写语句即应触发(与 allow_multi 语义一致)。
let stmts = parse("SELECT 1; UPDATE posts SET deleted_at = NULL WHERE id = 648");
assert!(writes_affect_cache(&stmts));
}
}

View File

@ -15,96 +15,26 @@
#![cfg(feature = "server")]
use katex::macros::MacroDefinition;
use katex::{KatexContext, OutputFormat, Settings};
/// 物理学常用宏表(对齐 LaTeX `physics` 宏包 + 项目文档 8.13 节「项目物理宏表」)。
///
/// `katex-rs` 默认 `Settings` 无物理宏表,导致 `\vu \dv \dd \pdv \divg \curl \grad`
/// `\qty \RR \ZZ \NN \QQ \CC \bra \ket \braket \expval \abs \norm` 等渲染为红字
/// (实测正确页面 648 的 137 个公式中 48 处物理宏坏掉)。这里把它们注册为简单
/// 字符串宏([`MacroDefinition::StaticStr`]crate 的 `string_to_expansion` 会自动
/// 从 `#1`/`#2` 推导参数个数。
///
/// 刻意差异:`\divg`(散度)**不**覆写内置 `\div`(除号 ÷)——文档 8.13 明确两者并存。
/// `\bra`/`\ket`/`\braket` 虽是 katex 内置宏,但内置 `\braket` 只吃 1 个参数
/// `\langle{#1}\rangle`),物理语义需 2 个参数(`\langle #1 | #2 \rangle`
/// 故覆写为物理版本。
///
/// `\qty(...)` 的圆括号定界符匹配无法用纯字符串宏表达TeX 无参定界符宏需
/// `MacroExpansion.delimiters`),由 [`render_inline`]/[`render_display` 渲染前的
/// 预处理兜底;这里注册的是花括号形式 `\qty{...}`。
fn physics_macros() -> &'static [(&'static str, MacroDefinition)] {
&[
// 数集
(r"\RR", MacroDefinition::StaticStr(r"\mathbb{R}")),
(r"\ZZ", MacroDefinition::StaticStr(r"\mathbb{Z}")),
(r"\NN", MacroDefinition::StaticStr(r"\mathbb{N}")),
(r"\QQ", MacroDefinition::StaticStr(r"\mathbb{Q}")),
(r"\CC", MacroDefinition::StaticStr(r"\mathbb{C}")),
// 微积分:微分与偏导
(r"\dd", MacroDefinition::StaticStr(r"\mathrm{d}#1")),
(
r"\dv",
MacroDefinition::StaticStr(r"\frac{\mathrm{d}#1}{\mathrm{d}#2}"),
),
(
r"\pdv",
MacroDefinition::StaticStr(r"\frac{\partial #1}{\partial #2}"),
),
// 场算子grad/divg/curldivg 刻意不复用 \div
(r"\grad", MacroDefinition::StaticStr(r"\nabla")),
(r"\divg", MacroDefinition::StaticStr(r"\nabla \cdot")),
(r"\curl", MacroDefinition::StaticStr(r"\nabla \times")),
// 量子力学 Dirac 记号
(r"\bra", MacroDefinition::StaticStr(r"\langle #1 |")),
(r"\ket", MacroDefinition::StaticStr(r"| #1 \rangle")),
(
r"\braket",
MacroDefinition::StaticStr(r"\langle #1 | #2 \rangle"),
),
(r"\expval", MacroDefinition::StaticStr(r"\langle #1 \rangle")),
// 向量 / 范数 / 绝对值(自动缩放定界符)
(r"\abs", MacroDefinition::StaticStr(r"\left| #1 \right|")),
(r"\norm", MacroDefinition::StaticStr(r"\left\| #1 \right\|")),
// 单位向量:带帽子
(r"\vu", MacroDefinition::StaticStr(r"\hat{\vec{#1}}")),
// 自动缩放圆括号(花括号形式;`\qty(...)` 由预处理兜底)
(r"\qty", MacroDefinition::StaticStr(r"\left( #1 \right)")),
]
}
/// 把物理宏表注入到给定 `Settings` 的宏表(覆盖同名内置宏)。
fn inject_physics_macros(settings: &mut Settings) {
let mut map = settings.macros.borrow_mut();
for (name, def) in physics_macros() {
map.insert((*name).to_string(), def.clone());
}
}
/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`,含物理宏表。
/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`。
fn inline_settings() -> Settings {
let mut s = Settings {
Settings {
output: OutputFormat::Html,
display_mode: false,
throw_on_error: false,
..Settings::default()
};
inject_physics_macros(&mut s);
s
}
}
/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行),含物理宏表
/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行)。
fn display_settings() -> Settings {
let mut s = Settings {
Settings {
output: OutputFormat::Html,
display_mode: true,
throw_on_error: false,
..Settings::default()
};
inject_physics_macros(&mut s);
s
}
}
thread_local! {
@ -119,89 +49,14 @@ thread_local! {
static DISPLAY_SETTINGS: Settings = display_settings();
}
/// 把公式中的 `\ce{...}` / `\pu{...}` 预转译为标准 LaTeXmhchem
///
/// `katex-rs` 无 mhchem 解析器,化学公式渲染为红字。这里在渲染前扫描 `\ce`/`\pu`
/// 调用,用嵌套花括号配对读取参数(支持 `\ce{[Cu(NH3)4]^2+}` 这类含 `{}` 的内容),
/// 转译后替换原 `\ce{...}`,其余文本原样拼接。未闭合 `\ce{` 保留原样(让 katex
/// 报红,符合容错设计)。无 `\ce`/`\pu` 时零成本原样返回。
fn expand_chem(tex: &str) -> String {
// 快速路径:绝大多数公式不含化学公式,避免分配。
if !tex.contains(r"\ce") && !tex.contains(r"\pu") {
return tex.to_string();
}
let bytes = tex.as_bytes();
let mut out = String::with_capacity(tex.len());
let mut i = 0;
while i < bytes.len() {
// 匹配 `\ce{` 或 `\pu{`
if bytes[i] == b'\\' && i + 3 < bytes.len() {
let (is_ce, is_pu) = (bytes[i + 1] == b'c' && bytes[i + 2] == b'e', bytes[i + 1] == b'p' && bytes[i + 2] == b'u');
let brace_at = if is_ce {
Some(i + 3)
} else if is_pu {
Some(i + 4)
} else {
None
};
if let Some(bi) = brace_at {
// 精确匹配命令边界:\ce/\pu 后须紧跟 `{`(否则可能是 \cellbox 之类)
if bi < bytes.len() && bytes[bi] == b'{' {
// 读配对花括号内容(处理嵌套)
if let Some((content, close_end)) = read_braced(tex, bi) {
let translated = if is_ce {
crate::api::mhchem::ce(content)
} else {
crate::api::mhchem::pu(content)
};
out.push_str(&translated);
i = close_end;
continue;
}
// 未闭合 `{`:原样输出剩余,交由 katex 报红
out.push_str(&tex[i..]);
return out;
}
}
}
out.push(bytes[i] as char);
i += 1;
}
out
}
/// 从 `open`(指向 `{`)读取配对花括号内容,返回 `(内容, 闭括号后位置)`。
/// 不闭合返回 `None`。嵌套 `{}` 正确计数。
fn read_braced(s: &str, open: usize) -> Option<(&str, usize)> {
let bytes = s.as_bytes();
debug_assert_eq!(bytes[open], b'{');
let mut depth = 0i32;
let mut i = open;
while i < bytes.len() {
match bytes[i] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some((&s[open + 1..i], i + 1));
}
}
_ => {}
}
i += 1;
}
None
}
/// 渲染内联公式 `$...$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
///
/// 渲染失败(坏 TeX时回退到 HTML 转义后的原文,保证文章不因一个坏公式全篇崩。
pub fn render_inline(tex: &str) -> String {
let tex = expand_chem(tex);
KATEX_CTX.with(|ctx| {
INLINE_SETTINGS.with(|settings| {
katex::render_to_string(ctx, &tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
katex::render_to_string(ctx, tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
})
})
}
@ -211,11 +66,10 @@ pub fn render_inline(tex: &str) -> String {
/// 与 [`render_inline`] 同样在失败时回退到转义原文。调用方负责块级包裹
/// (如 `<p class="math-display">`),这里只产出 KaTeX 的 span 串。
pub fn render_display(tex: &str) -> String {
let tex = expand_chem(tex);
KATEX_CTX.with(|ctx| {
DISPLAY_SETTINGS.with(|settings| {
katex::render_to_string(ctx, &tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
katex::render_to_string(ctx, tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
})
})
}
@ -260,135 +114,4 @@ mod tests {
"Html 输出不应含 <math> 标签, got: {html}"
);
}
// ── 物理宏表Fix 3a ─────────────────────────────────────────
// katex-rs 默认无物理宏,未注册时 \vu \dd \RR 等渲染为 katex-error 红字。
#[test]
fn physics_macro_unit_vector_renders() {
// \vu{i} → \hat{\vec{i}}:带帽子单位向量。
let html = render_inline(r"\vu{i}");
assert!(
html.contains("katex") && !html.contains("katex-error"),
"\\vu 应正确渲染而非红字, got: {html}"
);
}
#[test]
fn physics_macro_divergence_does_not_override_division() {
// 刻意差异:\divg散度与 \div除号 ÷)并存。
let divg = render_inline(r"\divg \vec{F}");
let div = render_inline(r"a \div b");
assert!(
!divg.contains("katex-error"),
"\\divg 应正确渲染而非红字, got: {divg}"
);
assert!(
!div.contains("katex-error"),
"\\div 应仍是除号而非红字, got: {div}"
);
// 两者输出不同(\divg 展开为 \nabla \cdot\div 是除号符号)。
assert_ne!(divg, div, "\\divg 与 \\div 输出应不同");
}
#[test]
fn physics_macro_number_sets_renders() {
for m in [r"\RR", r"\ZZ", r"\NN", r"\QQ", r"\CC"] {
let html = render_inline(m);
assert!(
!html.contains("katex-error"),
"{m} 应正确渲染而非红字, got: {html}"
);
}
}
#[test]
fn physics_macro_calculus_renders() {
// \dv{f}{x} → d f / d x\pdv{f}{x} → ∂ f / ∂ x\dd{x} → dx。
for tex in [r"\dv{f}{x}", r"\pdv{f}{x}", r"\dd{x}"] {
let html = render_inline(tex);
assert!(
!html.contains("katex-error"),
"{tex} 应正确渲染而非红字, got: {html}"
);
}
}
#[test]
fn physics_macro_dirac_notation_renders() {
for tex in [
r"\bra{\psi}",
r"\ket{\phi}",
r"\braket{\psi}{\phi}",
r"\expval{A}",
] {
let html = render_inline(tex);
assert!(
!html.contains("katex-error"),
"{tex} 应正确渲染而非红字, got: {html}"
);
}
}
#[test]
fn physics_macro_abs_norm_qty_renders() {
for tex in [r"\abs{x}", r"\norm{v}", r"\qty{a + b}"] {
let html = render_inline(tex);
assert!(
!html.contains("katex-error"),
"{tex} 应正确渲染而非红字, got: {html}"
);
}
}
// ── mhchem 化学公式Fix 3b ──────────────────────────────────────
// \ce/\pu 预转译后渲染,不应出现 katex-error 红字。
#[test]
fn mhchem_water_renders() {
let html = render_inline(r"\ce{H2O}");
assert!(
html.contains("katex") && !html.contains("katex-error"),
"\\ce{{H2O}} 应正确渲染而非红字, got: {html}"
);
}
#[test]
fn mhchem_reaction_with_arrow_renders() {
let html = render_display(r"\ce{2H2 + O2 -> 2H2O}");
assert!(
!html.contains("katex-error"),
"反应方程式应正确渲染而非红字, got: {html}"
);
}
#[test]
fn mhchem_gas_arrow_superscript_renders() {
// 气体符号 ^ —— 转译后变成 \uparrow消解原 mhchem 行尾 ^ 解析错误
// (文档 8.20 这正是当前唯一 1 个 katex-error 的根因)。
let html = render_display(r"\ce{CaCO3 ->[\Delta] CaO + CO2 ^}");
assert!(
!html.contains("katex-error"),
"气体箭头公式应正确渲染而非红字, got: {html}"
);
}
#[test]
fn mhchem_pu_units_renders() {
let html = render_inline(r"\pu{9.8 m/s^2}");
assert!(
!html.contains("katex-error"),
"\\pu 单位应正确渲染而非红字, got: {html}"
);
}
#[test]
fn mhchem_ion_with_nested_braces_renders() {
// 嵌套花括号 / 络离子:扫描器必须正确配对 {}。
let html = render_inline(r"\ce{[Cu(NH3)4]^2+}");
assert!(
!html.contains("katex-error"),
"络离子公式应正确渲染而非红字, got: {html}"
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -23,9 +23,6 @@ pub mod image;
/// KaTeX 服务端数学公式渲染server-only
#[cfg(feature = "server")]
pub mod katex;
/// mhchem 化学公式转译器(\ce/\pu → LaTeXserver-only
#[cfg(feature = "server")]
pub mod mhchem;
/// Markdown 渲染与 HTML 清理。
pub mod markdown;
/// 文章 CRUD 相关接口。

View File

@ -635,16 +635,6 @@ pub async fn invalidate_pending_count() {
.await;
}
/// 全量失效评论缓存SQL 控制台兜底用:管理员直接改 comments 表时无法定向)。
///
/// 正常评论写路径用 [`invalidate_comments_by_post`](按文章定向)+ [`invalidate_pending_count`]
/// 这里全量清空是 SQL 控制台直改 DB 的兜底——无法从任意 SQL 精确解析受影响的 post_id。
#[cfg(feature = "server")]
pub fn invalidate_all_comments() {
COMMENT_CACHE.invalidate_all();
PENDING_COUNT_CACHE.invalidate_all();
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;