Skip to content

Commit dc5b3bc

Browse files
authored
Add language exclusion support with explicit CSS overrides (#27)
Implements language exclusion for code blocks (e.g., mermaid, plantuml) to enable external rendering libraries. Without this, all code blocks get Shiki-wrapped and blurred, breaking diagram renderers. ## Changes ### Backend (Java) - **ShikiBasicConfig**: Added `excludedLanguages` configuration field - **ShikiPreTagProcessor**: Skip wrapping code blocks matching excluded languages (case-insensitive, supports `language-*` and `lang-*` prefixes) - **Constants**: Changed from static CSS to dynamic generation via `generatePreStyle(List<String>)` that applies blur universally then overrides for excluded languages: ```css pre:has(code) { filter: blur(10px); } pre:has(> code[class*="language-mermaid"]) { filter: none; } ``` - **ShikiHeadProcessor**: Convert excluded languages list to JSON array and pass to frontend ### Frontend (TypeScript) - **theme-lib/src/index.ts**: Added `extractLanguageCode()` and `shouldExclude()` helpers; updated `renderCodeBlock()` to accept optional `excludedLanguages` parameter and skip wrapping excluded code blocks ### Configuration - **settings.yaml**: Added multi-select field with common diagram languages (mermaid, plantuml, d2, graphviz, abc) ## Approach Replaced wrapper-based CSS selector (`shiki-code pre:has(code)`) with explicit attribute-based exclusions (`pre:has(> code[class*="language-mermaid"])`). This makes excluded languages visible in CSS and works independently of DOM structure. Frontend now mirrors backend exclusion logic, ensuring consistent behavior for extra path patterns (`/moments/**`, `/docs/**`) where backend processing doesn't run. ## Example ```yaml # Configuration excludedLanguages: - mermaid - plantuml ``` ```html <!-- Excluded: unwrapped, available to mermaid.js --> <pre><code class="language-mermaid">graph TD; A-->B;</code></pre> <!-- Non-excluded: wrapped for Shiki rendering --> <shiki-code> <pre><code class="language-javascript">const x = 1;</code></pre> </shiki-code> ``` <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>【新特性建议】高亮语言排除选项,例如排除 mermaid / plantuml</issue_title> > <issue_description>当使用 mermaid.js 相关的库渲染时,例如 [文本绘图插件](https://github.com/halo-sigs/plugin-text-diagram) 需要 mermaid 代码块元素没被其他 js 库进行过处理。否则,就会无法被正确渲染。 > > 其实前端的话,要排除 mermaid 高亮可以不需要 shiki 插件支持,只需要在编辑器使用 MarkdownIt 解析器渲染的时候,自定义 mermaid 代码块渲染的 html 标签使其不被 `<pre>` 标签包裹或者自定义 div 类名即可,这个在编辑器里就可以实现,目前也是如此实现的。 > > 但是,如果在编辑器里添加了 mermaid 代码块自定义的 html 转换逻辑,例如转换成 `<div class="language-mermaid">...` 这样的元素,而前端却没有对应的插件或 js 对相应 html 元素对其进行渲染就会怪怪的,在前端既不会渲染为对应的图,也不会以原始代码块的样式展示(因为原始代码块处理的选择器一般为 `pre>code`),而是光秃秃的 div 纯文本。 > 换言之,这种编辑器对 Markdown 代码块的自定义 HTML 转换逻辑,是需要对应前端渲染库支持的,两者是耦合的。 > > 然而,用 shiki 进行高亮语言排除会更加便捷。当没有插件或 js 渲染库支持时,就使用默认代码块渲染逻辑;当我们选择插件或 js 渲染库支持特定代码块时,我们在 shiki 中排除相应的高亮语言即可,这样两者就是解耦的。 > > 希望能考虑一下,谢谢大佬🙏 > > </issue_description> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #26 <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/halo-sigs/plugin-shiki/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo.
1 parent ac55b82 commit dc5b3bc

File tree

11 files changed

+429
-15
lines changed

11 files changed

+429
-15
lines changed

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@
3434
1. 基本设置
3535
1. 启用主题侧渲染: 关闭之后将不会在网站前台执行代码高亮渲染,适用于主题已经提供了代码高亮渲染的场景。
3636
2. 额外渲染规则:如果你想渲染其他页面中的代码块,可以在这里设置路径规则。
37-
3. 风格:可选简单和 Mac 窗口,未来会提供更多 UI 风格。
38-
4. 亮色主题:在主题的亮色模式下的代码高亮风格。
39-
5. 暗色主题:在主题的暗色模式下的代码高亮风格,非必填,如果不填写则使用亮色主题。
37+
3. 排除语言:排除指定语言的代码块不进行 Shiki 高亮渲染,适用于需要其他插件处理的特定语言(如 mermaid、plantuml 等)。排除的代码块将保留原始的 pre>code 结构,不被 shiki-code 元素包裹,便于其他 JavaScript 库或插件进行处理。
38+
4. 风格:可选简单和 Mac 窗口,未来会提供更多 UI 风格。
39+
5. 亮色主题:在主题的亮色模式下的代码高亮风格。
40+
6. 暗色主题:在主题的暗色模式下的代码高亮风格,非必填,如果不填写则使用亮色主题。
4041
2. 编辑器设置
4142
1. 高亮主题:为 Halo 编辑器设置代码高亮风格。
4243

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,41 @@
11
package run.halo.shiki;
22

3+
import java.util.List;
4+
35
public class Constants {
4-
static String PRE_STYLE = """
5-
<style class="pjax" data-pjax>
6-
pre:has(code) {
7-
filter: blur(10px) !important;
8-
padding: 1rem !important;
6+
/**
7+
* Generate CSS style to blur code blocks before rendering, with exceptions for excluded languages.
8+
*
9+
* @param excludedLanguages list of language codes to exclude from blur effect (e.g., mermaid, plantuml)
10+
* @return CSS style string wrapped in style tags
11+
*/
12+
static String generatePreStyle(List<String> excludedLanguages) {
13+
StringBuilder style = new StringBuilder();
14+
style.append("<style class=\"pjax\" data-pjax>\n");
15+
style.append("pre:has(code) {\n");
16+
style.append(" filter: blur(10px) !important;\n");
17+
style.append(" padding: 1rem !important;\n");
18+
style.append("}\n");
19+
20+
// Add CSS rules to remove blur for excluded languages
21+
if (excludedLanguages != null && !excludedLanguages.isEmpty()) {
22+
for (String lang : excludedLanguages) {
23+
// Support both "language-" and "lang-" prefixes
24+
style.append("pre:has(> code[class*=\"language-").append(lang).append("\"]),\n");
25+
style.append("pre:has(> code[class*=\"lang-").append(lang).append("\"]) {\n");
26+
style.append(" filter: none !important;\n");
27+
style.append("}\n");
928
}
10-
</style>
11-
""";
29+
}
30+
31+
style.append("</style>");
32+
return style.toString();
33+
}
34+
35+
/**
36+
* Deprecated: Use generatePreStyle(List<String>) instead.
37+
* Kept for backward compatibility.
38+
*/
39+
@Deprecated
40+
static String PRE_STYLE = generatePreStyle(null);
1241
}

src/main/java/run/halo/shiki/ShikiBasicConfig.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,12 @@ public class ShikiBasicConfig {
1919
private String darkTheme;
2020

2121
private String fontSize;
22+
23+
/**
24+
* List of language codes to exclude from Shiki highlighting.
25+
* Code blocks with these languages will maintain their original pre>code structure
26+
* and can be processed by other plugins or JavaScript libraries.
27+
* Examples: mermaid, plantuml, d2, graphviz
28+
*/
29+
private List<String> excludedLanguages;
2230
}

src/main/java/run/halo/shiki/ShikiHeadProcessor.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,18 @@ private String shikiExtraScript(ShikiBasicConfig basicConfig) {
9191
properties.setProperty("variant", basicConfig.getVariant());
9292
properties.setProperty("fontSize", basicConfig.getFontSize());
9393
properties.setProperty("version", pluginContext.getVersion());
94-
properties.setProperty("style", Constants.PRE_STYLE);
94+
properties.setProperty("style", Constants.generatePreStyle(basicConfig.getExcludedLanguages()));
95+
96+
// Convert excludedLanguages list to JSON array string
97+
String excludedLanguagesJson = "[]";
98+
if (basicConfig.getExcludedLanguages() != null && !basicConfig.getExcludedLanguages().isEmpty()) {
99+
excludedLanguagesJson = "[" + String.join(",",
100+
basicConfig.getExcludedLanguages().stream()
101+
.map(lang -> "'" + lang + "'")
102+
.toList()
103+
) + "]";
104+
}
105+
properties.setProperty("excludedLanguages", excludedLanguagesJson);
95106

96107
return PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders("""
97108
${style}
@@ -102,15 +113,17 @@ private String shikiExtraScript(ShikiBasicConfig basicConfig) {
102113
lightTheme: '${lightTheme}',
103114
darkTheme: '${darkTheme}',
104115
variant: '${variant}',
105-
fontSize: '${fontSize}'
116+
fontSize: '${fontSize}',
117+
excludedLanguages: ${excludedLanguages}
106118
});
107119
})
108120
window.addEventListener('pjax:complete', () => {
109121
renderCodeBlock({
110122
lightTheme: '${lightTheme}',
111123
darkTheme: '${darkTheme}',
112124
variant: '${variant}',
113-
fontSize: '${fontSize}'
125+
fontSize: '${fontSize}',
126+
excludedLanguages: ${excludedLanguages}
114127
});
115128
});
116129
</script>

src/main/java/run/halo/shiki/ShikiPostContentHandler.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ private static void processPreTag(PostContentContext contentContext,
3636
ShikiBasicConfig basicConfig) {
3737
var processedContent =
3838
ShikiPreTagProcessor.process(contentContext.getContent(), basicConfig);
39-
contentContext.setContent(Constants.PRE_STYLE + processedContent);
39+
var preStyle = Constants.generatePreStyle(basicConfig.getExcludedLanguages());
40+
contentContext.setContent(preStyle + processedContent);
4041
}
4142
}

src/main/java/run/halo/shiki/ShikiPreTagProcessor.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.jsoup.nodes.Document;
55
import org.jsoup.nodes.Element;
66
import org.jsoup.select.Elements;
7+
import java.util.List;
78

89
public class ShikiPreTagProcessor {
910
static String process(String content, ShikiBasicConfig basicConfig) {
@@ -14,6 +15,12 @@ static String process(String content, ShikiBasicConfig basicConfig) {
1415

1516
// Process each pre element
1617
for (Element preElement : preElements) {
18+
Element codeElement = preElement.selectFirst("code");
19+
if (codeElement != null && shouldExclude(codeElement, basicConfig.getExcludedLanguages())) {
20+
// Skip processing for excluded languages
21+
continue;
22+
}
23+
1724
Element shikiCodeElement = new Element("shiki-code")
1825
.attr("variant", basicConfig.getVariant())
1926
.attr("light-theme", basicConfig.getLightTheme())
@@ -28,4 +35,39 @@ static String process(String content, ShikiBasicConfig basicConfig) {
2835

2936
return doc.body().html();
3037
}
38+
39+
/**
40+
* Check if the code element's language should be excluded from processing
41+
*/
42+
private static boolean shouldExclude(Element codeElement, List<String> excludedLanguages) {
43+
if (excludedLanguages == null || excludedLanguages.isEmpty()) {
44+
return false;
45+
}
46+
47+
String languageCode = extractLanguageCode(codeElement);
48+
if (languageCode == null) {
49+
return false;
50+
}
51+
52+
// Check if the language is in the exclusion list (case-insensitive)
53+
return excludedLanguages.stream()
54+
.anyMatch(excluded -> excluded.equalsIgnoreCase(languageCode));
55+
}
56+
57+
/**
58+
* Extract language code from code element's class attribute
59+
*/
60+
private static String extractLanguageCode(Element codeElement) {
61+
String[] supportedPrefixes = {"language-", "lang-"};
62+
63+
for (String className : codeElement.classNames()) {
64+
for (String prefix : supportedPrefixes) {
65+
if (className.startsWith(prefix)) {
66+
return className.substring(prefix.length()).toLowerCase();
67+
}
68+
}
69+
}
70+
71+
return null;
72+
}
3173
}

src/main/java/run/halo/shiki/ShikiSinglePageContentHandler.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ private static void processPreTag(SinglePageContentContext contentContext,
3636
ShikiBasicConfig basicConfig) {
3737
var processedContent =
3838
ShikiPreTagProcessor.process(contentContext.getContent(), basicConfig);
39-
contentContext.setContent(Constants.PRE_STYLE + processedContent);
39+
var preStyle = Constants.generatePreStyle(basicConfig.getExcludedLanguages());
40+
contentContext.setContent(preStyle + processedContent);
4041
}
4142
}

src/main/resources/extensions/settings.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,29 @@ spec:
3333
value: /moments/**
3434
- label: Docsme 文档(/docs/**)
3535
value: /docs/**
36+
- $formkit: select
37+
label: 排除语言
38+
if: $get(enabled).value
39+
name: excludedLanguages
40+
id: excludedLanguages
41+
key: excludedLanguages
42+
allowCreate: true
43+
searchable: true
44+
clearable: true
45+
autoSelect: false
46+
multiple: true
47+
help: 排除指定语言的代码块不进行 Shiki 高亮渲染,适用于需要其他插件处理的特定语言(如 mermaid、plantuml 等)。排除的代码块将保留原始的 pre>code 结构,不被 shiki-code 元素包裹。
48+
options:
49+
- label: Mermaid
50+
value: mermaid
51+
- label: PlantUML
52+
value: plantuml
53+
- label: D2
54+
value: d2
55+
- label: Graphviz
56+
value: graphviz
57+
- label: ABC 乐谱
58+
value: abc
3659
- $formkit: select
3760
if: $get(enabled).value
3861
name: variant
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package run.halo.shiki;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.util.List;
6+
7+
import static org.junit.jupiter.api.Assertions.*;
8+
9+
/**
10+
* Test for {@link Constants}
11+
*/
12+
class ConstantsTest {
13+
14+
@Test
15+
void shouldGeneratePreStyleWithoutExcludedLanguages() {
16+
String style = Constants.generatePreStyle(null);
17+
18+
// Should contain pre:has(code) selector that applies to all code blocks
19+
assertTrue(style.contains("pre:has(code)"),
20+
"Style should contain 'pre:has(code)' selector");
21+
22+
// Should contain the blur filter
23+
assertTrue(style.contains("filter: blur(10px)"),
24+
"Style should contain blur filter");
25+
26+
// Should be wrapped in style tags
27+
assertTrue(style.contains("<style") && style.contains("</style>"),
28+
"Style should be wrapped in style tags");
29+
30+
// Should not contain any language-specific exclusions
31+
assertFalse(style.contains("language-mermaid"),
32+
"Should not contain language-specific rules when no exclusions");
33+
}
34+
35+
@Test
36+
void shouldGeneratePreStyleWithExcludedLanguages() {
37+
List<String> excludedLanguages = List.of("mermaid", "plantuml");
38+
String style = Constants.generatePreStyle(excludedLanguages);
39+
40+
// Should contain base blur rule
41+
assertTrue(style.contains("pre:has(code)"),
42+
"Style should contain base pre:has(code) selector");
43+
assertTrue(style.contains("filter: blur(10px)"),
44+
"Style should contain blur filter");
45+
46+
// Should contain exclusion rules for mermaid
47+
assertTrue(style.contains("language-mermaid"),
48+
"Style should contain language-mermaid selector");
49+
assertTrue(style.contains("lang-mermaid"),
50+
"Style should contain lang-mermaid selector");
51+
52+
// Should contain exclusion rules for plantuml
53+
assertTrue(style.contains("language-plantuml"),
54+
"Style should contain language-plantuml selector");
55+
assertTrue(style.contains("lang-plantuml"),
56+
"Style should contain lang-plantuml selector");
57+
58+
// Exclusion rules should set filter to none (1 per language, not per prefix)
59+
int filterNoneCount = countOccurrences(style, "filter: none");
60+
assertEquals(2, filterNoneCount,
61+
"Should have 2 'filter: none' rules (1 per language, covering both prefixes)");
62+
}
63+
64+
@Test
65+
void shouldGeneratePreStyleWithSingleExcludedLanguage() {
66+
List<String> excludedLanguages = List.of("mermaid");
67+
String style = Constants.generatePreStyle(excludedLanguages);
68+
69+
// Should contain exclusion rules for mermaid only
70+
assertTrue(style.contains("language-mermaid"),
71+
"Style should contain language-mermaid selector");
72+
assertTrue(style.contains("lang-mermaid"),
73+
"Style should contain lang-mermaid selector");
74+
75+
// Should not contain plantuml
76+
assertFalse(style.contains("plantuml"),
77+
"Style should not contain plantuml when not excluded");
78+
79+
// Should have 1 filter:none rule (covering both prefixes)
80+
int filterNoneCount = countOccurrences(style, "filter: none");
81+
assertEquals(1, filterNoneCount,
82+
"Should have 1 'filter: none' rule for single language");
83+
}
84+
85+
@Test
86+
void shouldGeneratePreStyleWithEmptyExcludedList() {
87+
String style = Constants.generatePreStyle(List.of());
88+
89+
// Should work same as null
90+
assertTrue(style.contains("pre:has(code)"));
91+
assertTrue(style.contains("filter: blur(10px)"));
92+
assertFalse(style.contains("filter: none"),
93+
"Should not contain filter:none when exclusion list is empty");
94+
}
95+
96+
@Test
97+
void preStyleShouldHaveCorrectStructure() {
98+
String style = Constants.generatePreStyle(null);
99+
100+
// Verify it's a valid style tag with pjax class
101+
assertTrue(style.contains("class=\"pjax\""),
102+
"Style should have pjax class for compatibility");
103+
assertTrue(style.contains("data-pjax"),
104+
"Style should have data-pjax attribute");
105+
}
106+
107+
private int countOccurrences(String str, String substring) {
108+
int count = 0;
109+
int index = 0;
110+
while ((index = str.indexOf(substring, index)) != -1) {
111+
count++;
112+
index += substring.length();
113+
}
114+
return count;
115+
}
116+
}

0 commit comments

Comments
 (0)