feat(highlight): 文章页代码块支持 Vue SFC 语法高亮
阅读侧(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 / 大小写别名)。
This commit is contained in:
parent
6fee07df1f
commit
156b51d697
@ -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-');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,小写化)。
|
||||
*
|
||||
|
||||
@ -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
174
syntaxes/Vue.sublime-syntax
Normal 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+'
|
||||
Loading…
x
Reference in New Issue
Block a user