Skip to content

Commit 66413a3

Browse files
committed
add ability to reload highlighter
1 parent 7158ba9 commit 66413a3

File tree

9 files changed

+307
-213
lines changed

9 files changed

+307
-213
lines changed

meta.txt

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8486,7 +8486,7 @@
84868486
"format": "esm"
84878487
},
84888488
"src/codemirror/Cm6_ViewPlugin.ts": {
8489-
"bytes": 6551,
8489+
"bytes": 7037,
84908490
"imports": [
84918491
{
84928492
"path": "src/main.ts",
@@ -8522,12 +8522,17 @@
85228522
"path": "shiki",
85238523
"kind": "import-statement",
85248524
"external": true
8525+
},
8526+
{
8527+
"path": "obsidian",
8528+
"kind": "import-statement",
8529+
"external": true
85258530
}
85268531
],
85278532
"format": "esm"
85288533
},
85298534
"src/settings/Settings.ts": {
8530-
"bytes": 229,
8535+
"bytes": 286,
85318536
"imports": [],
85328537
"format": "esm"
85338538
},
@@ -8543,7 +8548,7 @@
85438548
"format": "esm"
85448549
},
85458550
"src/settings/SettingsTab.ts": {
8546-
"bytes": 2366,
8551+
"bytes": 2747,
85478552
"imports": [
85488553
{
85498554
"path": "obsidian",
@@ -8710,6 +8715,11 @@
87108715
"kind": "require-call",
87118716
"external": true
87128717
},
8718+
{
8719+
"path": "obsidian",
8720+
"kind": "require-call",
8721+
"external": true
8722+
},
87138723
{
87148724
"path": "obsidian",
87158725
"kind": "require-call",
@@ -10121,16 +10131,16 @@
1012110131
"bytesInOutput": 1030
1012210132
},
1012310133
"src/codemirror/Cm6_ViewPlugin.ts": {
10124-
"bytesInOutput": 2189
10134+
"bytesInOutput": 2280
1012510135
},
1012610136
"src/codemirror/Cm6_Util.ts": {
1012710137
"bytesInOutput": 323
1012810138
},
1012910139
"src/settings/Settings.ts": {
10130-
"bytesInOutput": 74
10140+
"bytesInOutput": 96
1013110141
},
1013210142
"src/settings/SettingsTab.ts": {
10133-
"bytesInOutput": 1607
10143+
"bytesInOutput": 1923
1013410144
},
1013510145
"src/settings/StringSelectModal.ts": {
1013610146
"bytesInOutput": 216
@@ -10145,7 +10155,7 @@
1014510155
"bytesInOutput": 3662
1014610156
}
1014710157
},
10148-
"bytes": 8944517
10158+
"bytes": 8944946
1014910159
}
1015010160
}
1015110161
}

src/CodeBlock.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { type MarkdownPostProcessorContext, MarkdownRenderChild } from 'obsidian';
22
import type ShikiPlugin from 'src/main';
3-
import { toHtml } from 'hast-util-to-html';
43

54
export class CodeBlock extends MarkdownRenderChild {
65
plugin: ShikiPlugin;
@@ -42,16 +41,7 @@ export class CodeBlock extends MarkdownRenderChild {
4241
}
4342

4443
private async render(metaString: string): Promise<void> {
45-
const renderResult = await this.plugin.ec.render({
46-
code: this.source,
47-
language: this.language,
48-
meta: metaString,
49-
});
50-
51-
const ast = this.plugin.themeMapper.fixAST(renderResult.renderedGroupAst);
52-
53-
// yes, this is innerHTML, but we trust hast
54-
this.containerEl.innerHTML = toHtml(ast);
44+
await this.plugin.highlighter.renderWithEc(this.source, this.language, metaString, this.containerEl);
5545
}
5646

5747
public async rerenderOnNoteChange(): Promise<void> {
@@ -66,6 +56,10 @@ export class CodeBlock extends MarkdownRenderChild {
6656
}
6757
}
6858

59+
public async forceRerender(): Promise<void> {
60+
await this.render(this.cachedMetaString);
61+
}
62+
6963
public onload(): void {
7064
super.onload();
7165

src/Highlighter.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { ExpressiveCodeEngine, ExpressiveCodeTheme } from '@expressive-code/core';
2+
import type ShikiPlugin from 'src/main';
3+
import { LoadedLanguage } from 'src/LoadedLanguage';
4+
import { bundledLanguages, createHighlighter, type Highlighter } from 'shiki/index.mjs';
5+
import { ThemeMapper } from 'src/themes/ThemeMapper';
6+
import { pluginShiki } from '@expressive-code/plugin-shiki';
7+
import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-sections';
8+
import { pluginTextMarkers } from '@expressive-code/plugin-text-markers';
9+
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers';
10+
import { pluginFrames } from '@expressive-code/plugin-frames';
11+
import { getECTheme } from 'src/themes/ECTheme';
12+
import { normalizePath, Notice } from 'obsidian';
13+
import { DEFAULT_SETTINGS } from 'src/settings/Settings';
14+
import { toHtml } from '@expressive-code/core/hast';
15+
16+
interface CustomTheme {
17+
name: string;
18+
displayName: string;
19+
type: string;
20+
colors?: Record<string, unknown>[];
21+
tokenColors?: Record<string, unknown>[];
22+
}
23+
24+
// some languages break obsidian's `registerMarkdownCodeBlockProcessor`, so we blacklist them
25+
const languageNameBlacklist = new Set(['c++', 'c#', 'f#', 'mermaid']);
26+
27+
export class CodeHighlighter {
28+
plugin: ShikiPlugin;
29+
themeMapper: ThemeMapper;
30+
31+
ec!: ExpressiveCodeEngine;
32+
ecElements!: HTMLElement[];
33+
loadedLanguages!: Map<string, LoadedLanguage>;
34+
shiki!: Highlighter;
35+
customThemes!: CustomTheme[];
36+
37+
constructor(plugin: ShikiPlugin) {
38+
this.plugin = plugin;
39+
this.themeMapper = new ThemeMapper(this.plugin);
40+
}
41+
42+
async load(): Promise<void> {
43+
await this.loadCustomThemes();
44+
45+
await this.loadLanguages();
46+
47+
await this.loadEC();
48+
await this.loadShiki();
49+
}
50+
51+
async unload(): Promise<void> {
52+
this.unloadEC();
53+
}
54+
55+
async loadLanguages(): Promise<void> {
56+
this.loadedLanguages = new Map();
57+
58+
for (const [shikiLanguage, registration] of Object.entries(bundledLanguages)) {
59+
// the last element of the array is seemingly the most recent version of the language
60+
const language = (await registration()).default.at(-1);
61+
const shikiLanguageName = shikiLanguage as keyof typeof bundledLanguages;
62+
63+
if (language === undefined) {
64+
continue;
65+
}
66+
67+
for (const alias of [language.name, ...(language.aliases ?? [])]) {
68+
if (languageNameBlacklist.has(alias)) {
69+
continue;
70+
}
71+
72+
if (!this.loadedLanguages.has(alias)) {
73+
const newLanguage = new LoadedLanguage(alias);
74+
newLanguage.addLanguage(shikiLanguageName);
75+
76+
this.loadedLanguages.set(alias, newLanguage);
77+
}
78+
79+
this.loadedLanguages.get(alias)!.addLanguage(shikiLanguageName);
80+
}
81+
}
82+
83+
for (const [alias, language] of this.loadedLanguages) {
84+
if (language.languages.length === 1) {
85+
language.setDefaultLanguage(language.languages[0]);
86+
} else {
87+
const defaultLanguage = language.languages.find(lang => lang === alias);
88+
if (defaultLanguage !== undefined) {
89+
language.setDefaultLanguage(defaultLanguage);
90+
} else {
91+
console.warn(`No default language found for ${alias}, using the first language in the list`);
92+
language.setDefaultLanguage(language.languages[0]);
93+
}
94+
}
95+
}
96+
97+
for (const disabledLanguage of this.plugin.loadedSettings.disabledLanguages) {
98+
this.loadedLanguages.delete(disabledLanguage);
99+
}
100+
}
101+
102+
async loadCustomThemes(): Promise<void> {
103+
this.customThemes = [];
104+
105+
// custom themes are disabled unless users specify a folder for them in plugin settings
106+
if (!this.plugin.loadedSettings.customThemeFolder) return;
107+
108+
const themeFolder = normalizePath(this.plugin.loadedSettings.customThemeFolder);
109+
if (!(await this.plugin.app.vault.adapter.exists(themeFolder))) {
110+
new Notice(`${this.plugin.manifest.name}\nUnable to open custom themes folder: ${themeFolder}`, 5000);
111+
return;
112+
}
113+
114+
const themeList = await this.plugin.app.vault.adapter.list(themeFolder);
115+
const themeFiles = themeList.files.filter(f => f.toLowerCase().endsWith('.json'));
116+
117+
for (const themeFile of themeFiles) {
118+
const baseName = themeFile.substring(`${themeFolder}/`.length);
119+
try {
120+
const theme = JSON.parse(await this.plugin.app.vault.adapter.read(themeFile)) as CustomTheme;
121+
// validate that theme file JSON can be parsed and contains colors at a minimum
122+
if (!theme.colors && !theme.tokenColors) {
123+
throw Error('Invalid JSON theme file.');
124+
}
125+
// what metadata is available in the theme file depends on how it was created
126+
theme.displayName = theme.displayName ?? theme.name ?? baseName;
127+
theme.name = baseName.toLowerCase();
128+
theme.type = theme.type ?? 'both';
129+
130+
this.customThemes.push(theme);
131+
} catch (e) {
132+
new Notice(`${this.plugin.manifest.name}\nUnable to load custom theme: ${themeFile}`, 5000);
133+
console.warn(`Unable to load custom theme: ${themeFile}`, e);
134+
}
135+
}
136+
137+
// if the user's set theme cannot be loaded (e.g. it was deleted), fall back to default theme
138+
if (this.usesCustomTheme() && !this.customThemes.find(theme => theme.name === this.plugin.loadedSettings.theme)) {
139+
this.plugin.settings.theme = DEFAULT_SETTINGS.theme;
140+
this.plugin.loadedSettings.theme = DEFAULT_SETTINGS.theme;
141+
142+
await this.plugin.saveSettings();
143+
}
144+
145+
this.customThemes.sort((a, b) => a.displayName.localeCompare(b.displayName));
146+
}
147+
148+
async loadEC(): Promise<void> {
149+
this.ec = new ExpressiveCodeEngine({
150+
themes: [new ExpressiveCodeTheme(await this.themeMapper.getThemeForEC())],
151+
plugins: [
152+
pluginShiki({
153+
langs: Object.values(bundledLanguages),
154+
}),
155+
pluginCollapsibleSections(),
156+
pluginTextMarkers(),
157+
pluginLineNumbers(),
158+
pluginFrames(),
159+
],
160+
styleOverrides: getECTheme(this.plugin.loadedSettings),
161+
minSyntaxHighlightingColorContrast: 0,
162+
themeCssRoot: 'div.expressive-code',
163+
defaultProps: {
164+
showLineNumbers: false,
165+
},
166+
});
167+
168+
this.ecElements = [];
169+
170+
const styles = (await this.ec.getBaseStyles()) + (await this.ec.getThemeStyles());
171+
this.ecElements.push(document.head.createEl('style', { text: styles }));
172+
173+
const jsModules = await this.ec.getJsModules();
174+
for (const jsModule of jsModules) {
175+
this.ecElements.push(document.head.createEl('script', { attr: { type: 'module' }, text: jsModule }));
176+
}
177+
}
178+
179+
unloadEC(): void {
180+
for (const el of this.ecElements) {
181+
el.remove();
182+
}
183+
this.ecElements = [];
184+
}
185+
186+
async loadShiki(): Promise<void> {
187+
this.shiki = await createHighlighter({
188+
themes: [await this.themeMapper.getTheme()],
189+
langs: Object.keys(bundledLanguages),
190+
});
191+
}
192+
193+
usesCustomTheme(): boolean {
194+
return this.plugin.loadedSettings.theme.endsWith('.json');
195+
}
196+
197+
/**
198+
* Highlights code with EC and renders it to the passed container element.
199+
*/
200+
async renderWithEc(code: string, language: string, meta: string, container: HTMLElement): Promise<void> {
201+
const result = await this.ec.render({
202+
code,
203+
language,
204+
meta,
205+
});
206+
207+
container.innerHTML = toHtml(this.themeMapper.fixAST(result.renderedGroupAst));
208+
}
209+
}

src/codemirror/Cm6_ViewPlugin.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,21 @@ import { Cm6_Util } from 'src/codemirror/Cm6_Util';
88
import { type ThemedToken } from 'shiki';
99
import { editorLivePreviewField } from 'obsidian';
1010

11-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
12-
export function createCm6Plugin(plugin: ShikiPlugin): ViewPlugin<any> {
11+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
12+
export function createCm6Plugin(plugin: ShikiPlugin) {
1313
return ViewPlugin.fromClass(
14-
class {
14+
class Cm6ViewPlugin {
1515
decorations: DecorationSet;
16+
view: EditorView;
1617

1718
constructor(view: EditorView) {
19+
this.view = view;
1820
this.decorations = Decoration.none;
1921
this.updateWidgets(view);
22+
23+
plugin.updateCm6Plugin = (): void => {
24+
this.forceUpdate();
25+
};
2026
}
2127

2228
/**
@@ -30,10 +36,17 @@ export function createCm6Plugin(plugin: ShikiPlugin): ViewPlugin<any> {
3036

3137
// we handle doc changes and selection changes here
3238
if (update.docChanged || update.selectionSet) {
39+
this.view = update.view;
3340
this.updateWidgets(update.view, update.docChanged);
3441
}
3542
}
3643

44+
forceUpdate(): void {
45+
this.updateWidgets(this.view);
46+
47+
this.view.dispatch(this.view.state.update({}));
48+
}
49+
3750
isLivePreview(state: EditorState): boolean {
3851
// @ts-ignore some strange private field not being assignable
3952
return state.field(editorLivePreviewField);

0 commit comments

Comments
 (0)