Compare commits

..

2 Commits

Author SHA1 Message Date
xfy
156b51d697 feat(highlight): 文章页代码块支持 Vue SFC 语法高亮
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
阅读侧(syntect)此前对 ```vue 回退纯文本:syntect 默认集无 Vue
语法、syntaxes/ 无定义、别名表无 vue 条目。

官方 Vue Component.sublime-syntax 因 extends: Packages/HTML/...
用 Sublime Text 包路径,syntect 的 add_from_folder 解析不了(实测
panic),无法直接采用。改为自包含实现:

- syntaxes/Vue.sublime-syntax:自包含 Vue SFC 语法。<template>/
  <script>/<style> 三段在 main 入口按标签名分流(syntect context
  切换后无法回看标签名)。script 按 lang 属性经前瞻分流
  ts/tsx→source.ts、其余→source.js;style 统一 embed source.css
  (scss/less 无对应 scope,因 CSS 是超集兼容);template 内 {{ }}
  插值 embed source.js,其余走内置 HTML 语法。
- 关键约束(syntect 5.3 实测):embed 规则必须用纯净 match+embed+
  escape 写法,附加 pop/embed_scope 会破坏 embed 使其静默回退纯文本;
  escape 用 lookahead 不消费闭合标签。

编辑器侧(lowlight@3.3.0)与阅读侧是独立链路。lowlight 依赖的
highlight.js 11 早已移除 vue grammar,common/all 均不含;
highlightjs-vue 包停在 2019 年 1.0.0 且用旧版 hljsDefineVue 全局
API,与 lowlight@3 的 ESM register 不兼容。故编辑器侧用 registerAlias
把 vue 注册为 xml 别名兜底(template 段按 XML 着色,script/style 段
不精确但不抛 Unknown language);读者看到的阅读侧仍是完整高亮。

src/highlight.rs 别名表加 vue 条目(兜底大写 ```Vue 边界),新增 3 个
Vue 高亮单测(SFC 三段 / lang=ts 走 TS / 大小写别名)。
2026-07-17 11:42:55 +08:00
xfy
6fee07df1f feat(markdown): 文章页脚注完整支持(语义化 + back-link + 样式)
切换到 pulldown-cmark 的 GFM 脚注模式(与 GitHub 行为一致),并用自定义
事件渲染器替换默认输出,补齐语义化结构与无障碍属性:

- 解析:Options::all() 同时置 ENABLE_OLD_FOOTNOTES(bit 9|2),使
  has_gfm_footnotes() 返回 false 走 OLD 模式。remove(OLD) 会连
  ENABLE_FOOTNOTES 一起清掉(OLD 位掩码含 bit 2),故 remove 后须
  insert(ENABLE_FOOTNOTES) 单独加回。
- 渲染:事件循环新增 FootnoteReference / Start/End FootnoteDefinition
  三个分支。引用处 <sup class="fn-ref" id="fnref:{label}-{n}"> 带
  role="doc-noteref";定义处 <aside role="doc-footnote"
  aria-labelledby="..."> 取代默认 <div>;End 处按引用次数输出 N 个
  back-link(↩、↩²、↩³…)指向各自引用位置,role="doc-backlink"。
- 编号:按 label 首次出现顺序分配(1,2,3…),与定义位置无关。第一遍
  遍历顺带统计(复用现有两遍遍历模式,零额外扫描成本)。
- id 安全:footnote_id() 保留 label 原文(不转拼音),仅把 ASCII 标点
  /空格转 -,确保 ref↔def 双向一致且不破坏 HTML 属性。

sanitizer:放行 <section> 与 aria-labelledby;修复 is_safe_url 对
含冒号锚点(如 #fn:label)的误判——fragment 内冒号不是 scheme 分隔符,
# 开头的 URL 优先按锚点放行。

input.css:仿 blockquote 视觉语言(左侧边框 + 次级文本色)补脚注样式,
复用 paper 色彩变量,暗色模式自动适配。
2026-07-17 10:57:40 +08:00
7 changed files with 723 additions and 3 deletions

View File

@ -445,6 +445,69 @@
margin-bottom: 0;
}
/* 脚注引用上标[^id] 渲染成的 <sup class="fn-ref">
* line-height: 0 防止上标撑高所在行行高font-size 缩小为 0.75em */
.md-content .fn-ref {
font-size: 0.75em;
line-height: 0;
vertical-align: super;
margin-inline-start: 0.1em;
}
/* 引用数字链接:不加下划线(上标下划线极难看),用 accent 色区分。 */
.md-content .fn-ref-link {
text-decoration: none;
color: var(--color-paper-accent);
}
.md-content .fn-ref-link:hover {
color: var(--color-paper-accent-2);
}
/* 脚注定义块:仿 blockquote 的左侧边框视觉语言但更轻0.2rem + 稍小字号)。 */
.md-content .footnote-definition {
margin: 1rem 0;
padding-inline-start: 1rem;
border-inline-start: 0.2rem solid var(--color-paper-content);
font-size: 0.875em;
color: var(--color-paper-secondary);
}
.md-content .footnote-definition:last-child {
margin-bottom: 0;
}
/* 定义内末段去底距,避免与 back-link 间距过大。 */
.md-content .footnote-definition > p:last-child {
margin-bottom: 0;
}
/* 定义编号上标:与正文引用数字呼应。 */
.md-content .footnote-definition-label {
font-size: 0.75em;
line-height: 0;
vertical-align: super;
margin-inline-end: 0.25em;
color: var(--color-paper-tertiary);
}
/* 返回正文链接:↩ 符号弱色hover 转 accent。 */
.md-content .fn-backref {
margin-inline-start: 0.25em;
text-decoration: none;
color: var(--color-paper-tertiary);
}
.md-content .fn-backref:hover {
color: var(--color-paper-accent);
}
/* 多引用时的数字上标(↩² 中的 ²)。 */
.md-content .fn-backref-num {
font-size: 0.75em;
vertical-align: super;
}
.md-content ol,
.md-content ul {
padding-inline-start: 1.25rem;

View File

@ -66,4 +66,12 @@ describe('lowlight wrapper', () => {
// CodeBlockLowlight 源码用 try/catch 兜底,这里只验 wrapper 不改变此行为。
expect(() => lowlight.highlight('totally-unknown-lang', 'code')).toThrow();
});
it('vue 作为 xml 别名高亮(不抛 Unknown language', () => {
// lowlight 依赖的 highlight.js 11 已移除 vue grammar
// highlight.ts 把 vue 注册为 xml 别名兜底。
// 验证vue 代码能高亮(走 xml grammar输出含 hljs class且不抛错。
const result = lowlight.highlight('vue', '<template><div>hi</div></template>');
expect(JSON.stringify(result)).toContain('hljs-');
});
});

View File

@ -2,6 +2,13 @@ import { common, createLowlight } from 'lowlight';
const base = createLowlight(common);
// Vue SFC 别名:lowlight 依赖的 highlight.js 11 早已移除 vue grammar,
// common/all 均不含 vue。这里把 vue 注册为 xml 的别名,使 ```vue 代码块
// 在编辑器写作时按 XML/HTML 着色(template 段正常,script/style 段不精确
// 但不抛 Unknown language)。阅读侧(读者看到的)走 syntect 自包含 Vue
// 语法(syntaxes/Vue.sublime-syntax),三段都有完整高亮,两侧独立互不影响。
base.registerAlias({ xml: ['vue'] });
/**
* fence info string token
*

View File

@ -40,7 +40,17 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
// 两遍遍历使用相同的 Options 与同一份解析结果,避免 TOC 收集与正文渲染对
// Markdown 扩展语法(表格、删除线、脚注等)的处理不一致。
let opts = Options::all();
//
// 脚注模式pulldown-cmark 的 ENABLE_OLD_FOOTNOTES = (1<<9)|(1<<2),它把
// ENABLE_FOOTNOTES 的 bit 也打进了 OLD 的位掩码里。Options::all() 同时置两者,
// 使 has_gfm_footnotes()=ENABLE_FOOTNOTES && !ENABLE_OLD_FOOTNOTES返回 false
// 走 OLD 模式续行宽松、label 可含换行)。我们想要 GFM 模式(与 GitHub 一致、
// 解析可控),所以不能简单地 remove(OLD)——那会连 ENABLE_FOOTNOTES 一起清掉。
// 正确做法:先 remove(OLD)(清掉 bit 9 + bit 2再 insert(ENABLE_FOOTNOTES)
// 单独把 bit 2 加回,使 has_gfm_footnotes() = true。
let mut opts = Options::all();
opts.remove(Options::ENABLE_OLD_FOOTNOTES);
opts.insert(Options::ENABLE_FOOTNOTES);
// pulldown-cmark 只解析一次collect 成 Vec<Event> 后两遍遍历复用。
// 旧实现对同一份 md 调用两次 Parser::new_ext等于两倍的 tokenize + 解析 CPU。
@ -52,6 +62,15 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
let mut headings: Vec<(u8, String, String)> = Vec::new();
let mut current_heading: Option<(u8, String)> = None;
// 脚注引用统计label → (引用次数, 首次出现序号)。
// pulldown-cmark 不保证定义移到文末、也不保证 ref 先于 def唯一可靠不变量是
// 每个唯一 label 的 FootnoteDefinition 只出现一次、FootnoteReference 每次引用触发一次。
// 所以 back-link 必须按 label 关联display_num 按 label 首次出现顺序分配。
// fn_order 记录 label 首次出现顺序用于分配稳定的显示编号1, 2, 3…
use std::collections::HashMap;
let mut fn_refs: HashMap<String, usize> = HashMap::new();
let mut fn_order: Vec<String> = Vec::new();
for event in &events {
match event {
Event::Start(Tag::Heading { level, .. }) => {
@ -81,10 +100,28 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
headings.push((lvl, text, id));
}
}
// 统计脚注引用:仅对 FootnoteReference 计数(含未被定义的悬空引用)。
// 悬空引用([^missing] 无定义)也会产生此事件,但第二遍不会有对应 def
// fn_refs 里的条目无害(查不到对应 def 时第二遍不会输出 back-link
Event::FootnoteReference(name) => {
let label = name.to_string();
let count = fn_refs.entry(label.clone()).or_insert(0);
*count += 1;
if *count == 1 {
fn_order.push(label);
}
}
_ => {}
}
}
// 按 label 首次出现顺序分配显示编号1-based。脚注定义内查此表取 display_num。
let fn_num: HashMap<&String, usize> = fn_order
.iter()
.enumerate()
.map(|(i, label)| (label, i + 1))
.collect();
// 2. Generate TOC HTML
let toc_html = generate_toc_html(&headings);
@ -101,6 +138,12 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
let mut code_runnable: Option<(String, String)> = None;
let mut code_buffer = String::new();
let mut non_heading_events: Vec<Event> = Vec::new();
// 第二遍维护的脚注引用计数label → 已渲染的引用序号(从 1 起)。
// 用于给每个 ref 分配 id 后缀 fnref:{label}-{n},并让 def 末尾的 back-link 对应到每个 ref。
let mut fn_ref_seen: HashMap<String, usize> = HashMap::new();
// 脚注定义栈Start(FootnoteDefinition) 压入 labelEnd 弹出。
// 脚注定义可嵌套def 内引用另一个脚注),用栈保证 End 配对到正确的 label。
let mut fn_def_stack: Vec<String> = Vec::new();
for event in events {
match event {
@ -246,6 +289,79 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
html.push_str(&crate::api::katex::render_display(&tex));
html.push_str("</p>");
}
Event::FootnoteReference(name) => {
// 先刷出累积的普通事件,再注入脚注引用标记。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
let label = name.to_string();
// 本 label 的第 n 次引用1-based用于 id 后缀。
let n = {
let entry = fn_ref_seen.entry(label.clone()).or_insert(0);
*entry += 1;
*entry
};
let id = footnote_id(&label);
// display_numlabel 首次出现顺序编号;悬空引用(无 def查不到时回退到引用序号 n。
let num = fn_num.get(&label).copied().unwrap_or(n);
// 上标引用id 供 back-link 回跳href 跳到定义role=doc-noteref 语义化。
let _ = write!(
html,
r##"<sup class="fn-ref" id="fnref:{id}-{n}"><a href="#fn:{id}" class="fn-ref-link" role="doc-noteref" aria-label="脚注 {num}">{num}</a></sup>"##
);
}
Event::Start(Tag::FootnoteDefinition(name)) => {
// 脚注定义开始:先刷出累积的普通事件,再用 <aside> 开启语义化容器。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
let label = name.to_string();
let id = footnote_id(&label);
let num = fn_num.get(&label).copied().unwrap_or_else(|| {
// 定义未被任何引用提及(悬空定义):用 fn_order 长度+1 兜底编号。
fn_order.len() + 1
});
// <aside role="doc-footnote">:取 pulldown-cmark 默认 <div> 的语义升级。
// aria-labelledby 指向 label 的 sup让屏幕阅读器朗读「脚注 N」。
let _ = write!(
html,
r##"<aside class="footnote-definition" id="fn:{id}" role="doc-footnote" aria-labelledby="fn:{id}-label"><sup class="footnote-definition-label" id="fn:{id}-label">{num}</sup> "##
);
fn_def_stack.push(label);
}
Event::End(TagEnd::FootnoteDefinition) => {
// 脚注定义结束:先刷出累积的脚注正文事件(定义内部的段落/列表等),
// 再按引用次数输出 N 个 back-link↩、↩²、↩³…最后闭合 <aside>。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
// 弹栈取当前定义的 label配对 Start。
if let Some(label) = fn_def_stack.pop() {
let id = footnote_id(&label);
let ref_count = fn_refs.get(&label).copied().unwrap_or(0);
// back-link 上标符号序列1 个引用用 ↩N 个引用用 ↩¹ ↩²…(首个不加数字)。
// 每个链接指向对应引用位置 fnref:{id}-{n}role=doc-backlink 语义化。
// ref_count=0悬空定义时不输出 back-link无引用可回跳
if ref_count > 0 {
// 首个 back-link裸 ↩。
let _ = write!(
html,
r##"<a href="#fnref:{id}-1" class="fn-backref" role="doc-backlink" aria-label="返回正文">↩</a>"##
);
// 从第 2 个引用起加数字上标。
for n in 2..=ref_count {
let _ = write!(
html,
r##" <a href="#fnref:{id}-{n}" class="fn-backref" role="doc-backlink" aria-label="返回正文 {n}">↩<sup class="fn-backref-num">{n}</sup></a>"##
);
}
}
}
html.push_str("</aside>");
}
_ => {
if in_heading {
// 标题内部只保留文本与行内代码,避免嵌套块级元素。
@ -443,6 +559,48 @@ fn slugify_heading(text: &str) -> String {
slug
}
#[cfg(feature = "server")]
/// 将脚注 label 转换为可用于 HTML id / href 锚点的安全标识符。
///
/// 与 `slugify_heading` 不同:脚注 label 是用户自定义的稳定引用键(`[^key]`
/// **不应**转拼音或回退通用词——需保留 label 原文形态以保证 ref↔def 双向一致、可读。
/// 策略ASCII 字母数字与 `-`/`_` 保留;其余 ASCII 字符(空格、标点、引号)转 `-`
/// 非 ASCII中文、emoji 等)保留原样。合并连续 `-`、去首尾 `-`。
///
/// GFM 模式下 label 单行不含换行,但仍可能含空格/标点,此函数确保产出的 id
/// (1) 不含 `"`/`<`/`>`/`&`/空格等破坏 HTML 属性或 URL 的字符;(2) 同一 label 必产出同一 id。
fn footnote_id(label: &str) -> String {
let mut out = String::with_capacity(label.len());
let mut prev_dash = true; // true = 当前不应再输出 `-`(开头或上一字符已是 `-`
for c in label.chars() {
if c.is_ascii_alphanumeric() || c == '_' {
out.push(c);
prev_dash = false;
} else if !c.is_ascii() {
// 非 ASCII中文等直接保留——id 允许任意非空白字符。
out.push(c);
prev_dash = false;
} else if !prev_dash {
// 其余 ASCII 字符(空格、标点、引号)转 `-`。
out.push('-');
prev_dash = true;
}
}
// 去除尾部可能的 `-`prev_dash 合并已保证首部无 `-`)。
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
// label 全为特殊字符的极端情况,给一个确定性回退。
out.push_str("fn");
}
out
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
@ -859,6 +1017,239 @@ console.log(1)
assert!(result.html.contains("已完成"));
}
#[test]
fn render_markdown_footnote_basic() {
// 端到端:单个脚注引用 + 定义,验证自定义渲染器的完整输出。
// pulldown-cmark (GFM 模式) 解析 → 自定义事件拦截渲染 → sanitizer 放行。
let result = render_markdown_enhanced("正文[^a]\n\n[^a]: 脚注内容\n");
// 脚注引用:上标 + 锚点跳转 + role 语义
assert!(
result.html.contains(r#"<sup class="fn-ref" id="fnref:a-1">"#),
"脚注引用上标应含正确 id, got: {}",
result.html
);
assert!(
result.html.contains(r##"href="#fn:a""##) && result.html.contains(r#"role="doc-noteref""#),
"引用链接应指向定义并带 noteref 角色, got: {}",
result.html
);
// 脚注定义:<aside> + role + aria-labelledby
assert!(
result.html.contains(r#"<aside class="footnote-definition" id="fn:a""#)
&& result.html.contains(r#"role="doc-footnote""#),
"定义应为 aside + doc-footnote 角色, got: {}",
result.html
);
// back-link单个引用显示 ↩
assert!(
result.html.contains("") && result.html.contains(r#"role="doc-backlink""#),
"单个引用应有 ↩ back-link, got: {}",
result.html
);
// back-link 指向引用位置
assert!(
result.html.contains(r##"href="#fnref:a-1""##),
"back-link 应指向引用位置, got: {}",
result.html
);
// 脚注正文保留
assert!(result.html.contains("脚注内容"));
// 不应残留 pulldown-cmark 默认的 <div class="footnote-definition">
assert!(
!result.html.contains(r#"<div class="footnote-definition""#),
"不应使用默认 div, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_multiple_refs() {
// 同一脚注被多次引用:每个引用独立 id定义末尾输出 N 个 back-link↩、↩²、↩³
let result = render_markdown_enhanced("第一处[^x],第二处[^x],第三处[^x]\n\n[^x]: 注\n");
// 3 个引用,各有独立 id 后缀
assert!(
result.html.contains(r#"id="fnref:x-1""#)
&& result.html.contains(r#"id="fnref:x-2""#)
&& result.html.contains(r#"id="fnref:x-3""#),
"3 次引用应有 3 个独立 id, got: {}",
result.html
);
// back-link 数量 = 引用次数3 个)
let backref_count = result.html.matches(r#"class="fn-backref""#).count();
assert_eq!(
backref_count, 3,
"3 次引用应产出 3 个 back-link, got: {}",
result.html
);
// 首个 back-link 无数字上标(↩),后续带数字(↩²、↩³)
assert!(
result.html.contains(r#">↩</a>"#),
"首个 back-link 应为裸 ↩, got: {}",
result.html
);
assert!(
result.html.contains("↩<sup class=\"fn-backref-num\">2</sup>")
&& result.html.contains("↩<sup class=\"fn-backref-num\">3</sup>"),
"第 2、3 个 back-link 应带数字上标, got: {}",
result.html
);
// 每个 back-link 指向不同引用位置
assert!(
result.html.contains(r##"href="#fnref:x-1""##)
&& result.html.contains(r##"href="#fnref:x-2""##)
&& result.html.contains(r##"href="#fnref:x-3""##),
"3 个 back-link 应分别指向 3 个引用位置, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_numbering_order() {
// 多个不同脚注:编号按 label 首次出现顺序分配1、2、3…与定义位置无关。
let result = render_markdown_enhanced(
"先引用第二个[^b],再第一个[^a]\n\n[^b]: B注\n\n[^a]: A注\n",
);
// b 先在正文被引用 → 编号 1a 后被引用 → 编号 2。
// 分别断言各自的引用链接块(由 id 唯一定位)。
// b 的引用id=fnref:b-1aria-label=脚注 1
assert!(
result.html.contains(r#"id="fnref:b-1""#)
&& result.html.contains(r#"aria-label="脚注 1""#),
"b(先出现)引用编号应为 1, got: {}",
result.html
);
// a 的引用id=fnref:a-1aria-label=脚注 2
assert!(
result.html.contains(r#"id="fnref:a-1""#)
&& result.html.contains(r#"aria-label="脚注 2""#),
"a(后出现)引用编号应为 2, got: {}",
result.html
);
// 两个定义都应存在
assert!(
result.html.contains(r#"id="fn:b""#) && result.html.contains(r#"id="fn:a""#),
"两个脚注定义都应存在, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_id_safety() {
// label 含空格/标点id 应被清洗ref↔def 双向匹配,不含破坏属性的字符。
let result = render_markdown_enhanced("引文[^my note]\n\n[^my note]: 内容\n");
// 空格应被转成 -ref 与 def 用同一个清洗后 id
assert!(
result.html.contains(r##"href="#fn:my-note""##),
"ref href 应用清洗后 id, got: {}",
result.html
);
assert!(
result.html.contains(r#"id="fn:my-note""#),
"def id 应用清洗后 id, got: {}",
result.html
);
// 不应含未转义的空格在 id/href 属性值中(会破坏属性或 URL
assert!(
!result.html.contains("fn:my note"),
"id 不应含空格, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_dangling_ref() {
// GFM 模式下,未定义的悬空引用 [^missing] 被当作字面文本(不发 FootnoteReference 事件)。
// 这是 GFM 与 OLD 模式的关键差异OLD 把它渲染成 dangling linkGFM 保持字面。
// 参见 pulldown-cmark lib.rs:712-713 注释。
let result = render_markdown_enhanced("这个[^missing]没有定义\n");
// 字面保留 [^missing],不渲染成上标
assert!(
result.html.contains("[^missing]"),
"GFM 模式下悬空引用应字面保留, got: {}",
result.html
);
assert!(
!result.html.contains("fn-ref"),
"悬空引用不应渲染成脚注上标, got: {}",
result.html
);
assert!(
!result.html.contains("footnote-definition"),
"悬空引用不应有定义块, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_gfm_mode() {
// GFM 模式验证:定义续行需缩进。未缩进的续行不会被纳入脚注定义。
// 这是 GFM 与 OLD 模式的关键差异——确认我们走的是 GFM。
// 用一个 GFM 下会严格解析的输入:定义后紧跟的未缩进段落应独立于脚注。
let result = render_markdown_enhanced("正文[^g]\n\n[^g]: 脚注第一行\n独立段落\n");
// 脚注定义存在
assert!(
result.html.contains(r#"id="fn:g""#),
"脚注定义应存在, got: {}",
result.html
);
// GFM 模式下「独立段落」是独立的 <p>,不在脚注定义内
assert!(
result.html.contains("独立段落"),
"独立段落内容应保留, got: {}",
result.html
);
}
// ---- footnote_id 单元测试 ----
#[test]
fn footnote_id_preserves_alphanumeric() {
assert_eq!(footnote_id("abc123"), "abc123");
assert_eq!(footnote_id("a_b-c"), "a_b-c");
}
#[test]
fn footnote_id_preserves_non_ascii() {
// 中文、emoji 等非 ASCII 字符原样保留HTML id 允许任意非空白字符)。
assert_eq!(footnote_id("参考文献1"), "参考文献1");
}
#[test]
fn footnote_id_replaces_spaces_and_punctuation() {
// 空格、标点转 -,连续合并。
assert_eq!(footnote_id("my note"), "my-note");
assert_eq!(footnote_id("a b!c?d"), "a-b-c-d");
}
#[test]
fn footnote_id_deterministic() {
// 同一 label 多次调用必产生同一 idref↔def 双向一致的前提)。
for label in ["a", "my note", "参考文献", "a!b@c#"] {
assert_eq!(footnote_id(label), footnote_id(label), "label {:?} 不确定", label);
}
}
#[test]
fn footnote_id_no_attribute_breaking_chars() {
// 产出的 id 不得含破坏 HTML 属性或 URL 的字符:" ' < > & 空格。
for label in ["a\"b", "x'y", "a<b>", "c&d", "e f", "a!@#$%^&*()b"] {
let id = footnote_id(label);
assert!(!id.contains('"'), "id {:?} 含双引号 (label {:?})", id, label);
assert!(!id.contains('\''), "id {:?} 含单引号 (label {:?})", id, label);
assert!(!id.contains('<'), "id {:?} 含 < (label {:?})", id, label);
assert!(!id.contains('>'), "id {:?} 含 > (label {:?})", id, label);
assert!(!id.contains('&'), "id {:?} 含 & (label {:?})", id, label);
assert!(!id.contains(' '), "id {:?} 含空格 (label {:?})", id, label);
}
}
#[test]
fn footnote_id_empty_fallback() {
// 全特殊字符的极端情况回退为 "fn"。
assert_eq!(footnote_id("!@#$"), "fn");
assert_eq!(footnote_id("!!!"), "fn");
}
#[test]
fn render_markdown_inline_math() {
// $...$ 内联公式pulldown-cmark (ENABLE_MATH) 解析 → katex 渲染成 span。

View File

@ -74,6 +74,7 @@ static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(||
"ruby",
"s",
"samp",
"section",
"small",
"span",
"strike",
@ -170,6 +171,12 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool)
if trimmed.is_empty() {
return true;
}
// 锚点(#开头优先放行fragment 内的冒号不是 scheme 分隔符
// (如脚注锚点 #fn:label不应被下面的 scheme 解析误判。
// 仍显式拒绝 javascript:/vbscript:(虽以 # 开头不可能,但保持防御一致)。
if trimmed.starts_with('#') {
return true;
}
// 解析 scheme 并与白名单对比;未知 scheme 默认拒绝。
if let Some(colon_pos) = trimmed.find(':') {
let scheme = &trimmed[..colon_pos];
@ -189,8 +196,8 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool)
// 任何其它 scheme 均拒绝file://、blob://、about:blank 等。
return false;
}
// 无 scheme 时只允许相对路径与锚点
trimmed.starts_with('#') || trimmed.starts_with('/')
// 无 scheme 时只允许相对路径
trimmed.starts_with('/')
}
#[cfg(feature = "server")]
@ -358,6 +365,7 @@ pub fn clean_html(input: &str) -> String {
"class",
"aria-hidden",
"aria-label",
"aria-labelledby",
"id",
"role",
"accesskey",

View File

@ -80,6 +80,8 @@ pub mod server {
("kotlin", "kt"),
("swift", "swift"),
("golang", "go"),
// Vue SFC:file_extensions 已含 vue,此条兜底大写 ```Vue 等边界。
("vue", "vue"),
];
for &(from, to) in aliases {
// 别名比较同样不区分大小写,保证 "RUST" 与 "rust" 等价。
@ -363,4 +365,71 @@ mod tests {
assert!(result.contains("string"), "Zig 字符串未被识别: {}", result);
assert!(result.contains("numeric"), "Zig 数字未被识别: {}", result);
}
#[test]
fn highlight_code_vue_sfc() {
// Vue SFC 三段(template HTML + script JS + style CSS)都应被识别,
// 不回退到纯文本(text plain)。
let code = "\
<template>
<div class=\"hello\" @click=\"onClick\">{{ message }}</div>
</template>
<script setup>
import { ref } from 'vue'
const message = ref('Hello Vue!')
</script>
<style scoped>
.hello { color: #42b983; }
</style>";
let result = highlight_code(code, Some("vue"));
assert!(
!result.contains(r#"<span class="text plain">"#),
"Vue 不应回退到纯文本: {}",
result
);
assert!(
result.contains("entity name tag"),
"Vue template 标签未被识别: {}",
result
);
assert!(
result.contains("source js"),
"Vue script 段未嵌入 JS 高亮: {}",
result
);
assert!(
result.contains("source css"),
"Vue style 段未嵌入 CSS 高亮: {}",
result
);
assert!(
result.contains("entity other attribute-name"),
"Vue 指令/属性未被识别: {}",
result
);
}
#[test]
fn highlight_code_vue_script_lang_ts() {
// <script lang="ts"> 应嵌入 TypeScript(scope source ts),而非 JS。
let code = "<script lang=\"ts\">\nconst x: number = 42\n</script>";
let result = highlight_code(code, Some("vue"));
assert!(
result.contains("source ts"),
"Vue lang=ts 应嵌入 TS: {}",
result
);
}
#[test]
fn highlight_code_vue_resolves_vue_alias() {
// 别名表 "vue" 与扩展名 "vue" 输出应一致。
let code = "<template><p>{{ msg }}</p></template>";
let by_alias = highlight_code(code, Some("vue"));
let by_upper = highlight_code(code, Some("Vue"));
// 大写标识经别名表 eq_ignore_ascii_case 回退,输出须与小写一致。
assert_eq!(by_alias, by_upper);
}
}

174
syntaxes/Vue.sublime-syntax Normal file
View File

@ -0,0 +1,174 @@
%YAML 1.2
---
# Vue Single-File Component (.vue) 语法高亮。
#
# 自包含实现:不依赖 `extends: Packages/HTML/...`(syntect 不认 ST 包路径),
# 改用 embed: scope: 引用本项目已有的 HTML/JS/TS/TSX/CSS scope。
#
# 关键约束(syntect 5.3 实测):
# - embed 规则必须用纯净写法 `match + embed + escape`,不能附加 pop/embed_scope,
# 否则 embed 失效并静默回退纯文本。
# - escape 用 lookahead `(?=...)` 不消费闭合标签,交给外层 context 处理。
#
# 分段策略:SFC 的 <template>/<script>/<style> 三段各自有独立的 tag 入口,
# 在 main 中按标签名直接 push 对应入口(syntect context 切换后无法回看标签名,
# 故必须一开始就分流)。script 按 lang 属性分发 ts/tsx/js;
# style 的 scss/less/pug 等本项目无对应 scope,但因 CSS 是其超集,
# 统一走 source.css(关键字/选择器高亮基本适用)。
name: Vue Component
scope: text.html.vue
file_extensions:
- vue
variables:
# Vue 指令/特殊属性名:v-if / @click / :prop / #slot / v-model.trim
vue_directive: '(?:\bv-[\w.-]+|@[\w.-]+|:[\w.-]+|#[\w-]+)'
contexts:
main:
# <template ...>:进入 template 段(HTML + 插值 + 指令)
- match: '(<)(template)(?=[\s/>])'
captures:
1: punctuation.definition.tag.begin.html
2: entity.name.tag.structure.template.html
push: template-tag-open
# <script ... lang="ts|tsx|typescript">:TS 分支(前瞻区分,syntect 无变量比较)
- match: '(<)(script)(?=[\s/>][^>]*\blang(?:uage)?\s*=\s*[''"](ts|tsx|typescript)[''"])'
captures:
1: punctuation.definition.tag.begin.html
2: entity.name.tag.structure.script.html
push: script-tag-open-ts
# <script ...>:JS 分支(无 lang 或 lang="js")
- match: '(<)(script)(?=[\s/>])'
captures:
1: punctuation.definition.tag.begin.html
2: entity.name.tag.structure.script.html
push: script-tag-open
# <style ...>:进入 style 段(CSS)
- match: '(<)(style)(?=[\s/>])'
captures:
1: punctuation.definition.tag.begin.html
2: entity.name.tag.structure.style.html
push: style-tag-open
# 其余内容(裸 HTML 标签、文本、注释、DOCTYPE):复用 syntect 内置 HTML 语法
- include: scope:text.html.basic
script-tag-open-ts:
- meta_scope: meta.tag.structure.script.begin.html
- include: tag-attributes
- match: '/>'
scope: punctuation.definition.tag.end.html
pop: 1
- match: '>'
scope: punctuation.definition.tag.end.html
embed: scope:source.ts
escape: '(?=</script[\s>])'
###[ TEMPLATE ]#############################################################
template-tag-open:
- meta_scope: meta.tag.structure.template.begin.html
- include: tag-attributes
- match: '/>'
scope: punctuation.definition.tag.end.html
pop: 1
- match: '>'
scope: punctuation.definition.tag.end.html
push: template-content
template-content:
- meta_include_prototype: false
- meta_content_scope: meta.template.vue
# 闭合 </template>:结束段
- match: '(?=</template[\s>])'
pop: 1
# Vue 插值 {{ ... }}:embed JS 表达式
- match: '\{\{'
scope: punctuation.section.interpolation.begin.html
embed: scope:source.js
escape: '\}\}'
escape_captures:
0: punctuation.section.interpolation.end.html
# 其余:按 HTML 处理(标签、文本、属性),同时识别 Vue 指令
- include: scope:text.html.basic
###[ SCRIPT ]###############################################################
# 分流在 main 完成:含 lang="ts|tsx|typescript" 的 <script> → script-tag-open-ts;
# 其余(无 lang 或 lang="js")→ script-tag-open(embed JS)。
# syntect 无变量比较,无法在单 context 内按 lang 值动态选 embed 目标,
# 故用两条前瞻 match 在入口处分流。
script-tag-open:
- meta_scope: meta.tag.structure.script.begin.html
- include: tag-attributes
- match: '/>'
scope: punctuation.definition.tag.end.html
pop: 1
- match: '>'
scope: punctuation.definition.tag.end.html
embed: scope:source.js
escape: '(?=</script[\s>])'
###[ STYLE ]################################################################
style-tag-open:
- meta_scope: meta.tag.structure.style.begin.html
- include: tag-attributes
- match: '/>'
scope: punctuation.definition.tag.end.html
pop: 1
- match: '>'
scope: punctuation.definition.tag.end.html
embed: scope:source.css
escape: '(?=</style[\s>])'
###[ SHARED ]###############################################################
# 标签属性扫描:Vue 指令 + 普通属性 + 值。供 template/script/style 的 open context 复用。
tag-attributes:
# Vue 指令(v-if / @click / :prop / #slot)优先,标为属性名
- match: '{{vue_directive}}\s*(=)?'
scope: entity.other.attribute-name.html
# 普通属性名 = 值
- match: '\b[A-Za-z-][\w:-]*\s*(=)'
scope: entity.other.attribute-name.html
push: maybe-attr-value
# 布尔属性
- match: '\b[A-Za-z-][\w:-]*'
scope: entity.other.attribute-name.html
- include: whitespace
maybe-attr-value:
- include: whitespace
- match: '"'
scope: punctuation.definition.string.begin.html
set: double-quoted-attr-value
- match: "'"
scope: punctuation.definition.string.begin.html
set: single-quoted-attr-value
- match: '(?=\S)'
pop: 1
double-quoted-attr-value:
- meta_scope: string.quoted.double.html
- match: '"'
scope: punctuation.definition.string.end.html
pop: 1
- include: attr-value-escapes
single-quoted-attr-value:
- meta_scope: string.quoted.single.html
- match: "'"
scope: punctuation.definition.string.end.html
pop: 1
- include: attr-value-escapes
attr-value-escapes:
- match: '&[a-zA-Z]+;|&#\d+;|&#x[0-9A-Fa-f]+;'
scope: constant.character.entity.html
whitespace:
- match: '\s+'