Skip to content

Commit

Permalink
add ability to reload highlighter
Browse files Browse the repository at this point in the history
  • Loading branch information
mProjectsCode committed Sep 8, 2024
1 parent 7158ba9 commit 66413a3
Show file tree
Hide file tree
Showing 9 changed files with 307 additions and 213 deletions.
File renamed without changes.
24 changes: 17 additions & 7 deletions meta.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8486,7 +8486,7 @@
"format": "esm"
},
"src/codemirror/Cm6_ViewPlugin.ts": {
"bytes": 6551,
"bytes": 7037,
"imports": [
{
"path": "src/main.ts",
Expand Down Expand Up @@ -8522,12 +8522,17 @@
"path": "shiki",
"kind": "import-statement",
"external": true
},
{
"path": "obsidian",
"kind": "import-statement",
"external": true
}
],
"format": "esm"
},
"src/settings/Settings.ts": {
"bytes": 229,
"bytes": 286,
"imports": [],
"format": "esm"
},
Expand All @@ -8543,7 +8548,7 @@
"format": "esm"
},
"src/settings/SettingsTab.ts": {
"bytes": 2366,
"bytes": 2747,
"imports": [
{
"path": "obsidian",
Expand Down Expand Up @@ -8710,6 +8715,11 @@
"kind": "require-call",
"external": true
},
{
"path": "obsidian",
"kind": "require-call",
"external": true
},
{
"path": "obsidian",
"kind": "require-call",
Expand Down Expand Up @@ -10121,16 +10131,16 @@
"bytesInOutput": 1030
},
"src/codemirror/Cm6_ViewPlugin.ts": {
"bytesInOutput": 2189
"bytesInOutput": 2280
},
"src/codemirror/Cm6_Util.ts": {
"bytesInOutput": 323
},
"src/settings/Settings.ts": {
"bytesInOutput": 74
"bytesInOutput": 96
},
"src/settings/SettingsTab.ts": {
"bytesInOutput": 1607
"bytesInOutput": 1923
},
"src/settings/StringSelectModal.ts": {
"bytesInOutput": 216
Expand All @@ -10145,7 +10155,7 @@
"bytesInOutput": 3662
}
},
"bytes": 8944517
"bytes": 8944946
}
}
}
16 changes: 5 additions & 11 deletions src/CodeBlock.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { type MarkdownPostProcessorContext, MarkdownRenderChild } from 'obsidian';
import type ShikiPlugin from 'src/main';
import { toHtml } from 'hast-util-to-html';

export class CodeBlock extends MarkdownRenderChild {
plugin: ShikiPlugin;
Expand Down Expand Up @@ -42,16 +41,7 @@ export class CodeBlock extends MarkdownRenderChild {
}

private async render(metaString: string): Promise<void> {
const renderResult = await this.plugin.ec.render({
code: this.source,
language: this.language,
meta: metaString,
});

const ast = this.plugin.themeMapper.fixAST(renderResult.renderedGroupAst);

// yes, this is innerHTML, but we trust hast
this.containerEl.innerHTML = toHtml(ast);
await this.plugin.highlighter.renderWithEc(this.source, this.language, metaString, this.containerEl);
}

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

public async forceRerender(): Promise<void> {
await this.render(this.cachedMetaString);
}

public onload(): void {
super.onload();

Expand Down
209 changes: 209 additions & 0 deletions src/Highlighter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { ExpressiveCodeEngine, ExpressiveCodeTheme } from '@expressive-code/core';
import type ShikiPlugin from 'src/main';
import { LoadedLanguage } from 'src/LoadedLanguage';
import { bundledLanguages, createHighlighter, type Highlighter } from 'shiki/index.mjs';
import { ThemeMapper } from 'src/themes/ThemeMapper';
import { pluginShiki } from '@expressive-code/plugin-shiki';
import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-sections';
import { pluginTextMarkers } from '@expressive-code/plugin-text-markers';
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers';
import { pluginFrames } from '@expressive-code/plugin-frames';
import { getECTheme } from 'src/themes/ECTheme';
import { normalizePath, Notice } from 'obsidian';
import { DEFAULT_SETTINGS } from 'src/settings/Settings';
import { toHtml } from '@expressive-code/core/hast';

interface CustomTheme {
name: string;
displayName: string;
type: string;
colors?: Record<string, unknown>[];
tokenColors?: Record<string, unknown>[];
}

// some languages break obsidian's `registerMarkdownCodeBlockProcessor`, so we blacklist them
const languageNameBlacklist = new Set(['c++', 'c#', 'f#', 'mermaid']);

export class CodeHighlighter {
plugin: ShikiPlugin;
themeMapper: ThemeMapper;

ec!: ExpressiveCodeEngine;
ecElements!: HTMLElement[];
loadedLanguages!: Map<string, LoadedLanguage>;
shiki!: Highlighter;
customThemes!: CustomTheme[];

constructor(plugin: ShikiPlugin) {
this.plugin = plugin;
this.themeMapper = new ThemeMapper(this.plugin);
}

async load(): Promise<void> {
await this.loadCustomThemes();

await this.loadLanguages();

await this.loadEC();
await this.loadShiki();
}

async unload(): Promise<void> {
this.unloadEC();
}

async loadLanguages(): Promise<void> {
this.loadedLanguages = new Map();

for (const [shikiLanguage, registration] of Object.entries(bundledLanguages)) {
// the last element of the array is seemingly the most recent version of the language
const language = (await registration()).default.at(-1);
const shikiLanguageName = shikiLanguage as keyof typeof bundledLanguages;

if (language === undefined) {
continue;
}

for (const alias of [language.name, ...(language.aliases ?? [])]) {
if (languageNameBlacklist.has(alias)) {
continue;
}

if (!this.loadedLanguages.has(alias)) {
const newLanguage = new LoadedLanguage(alias);
newLanguage.addLanguage(shikiLanguageName);

this.loadedLanguages.set(alias, newLanguage);
}

this.loadedLanguages.get(alias)!.addLanguage(shikiLanguageName);
}
}

for (const [alias, language] of this.loadedLanguages) {
if (language.languages.length === 1) {
language.setDefaultLanguage(language.languages[0]);
} else {
const defaultLanguage = language.languages.find(lang => lang === alias);
if (defaultLanguage !== undefined) {
language.setDefaultLanguage(defaultLanguage);
} else {
console.warn(`No default language found for ${alias}, using the first language in the list`);
language.setDefaultLanguage(language.languages[0]);
}
}
}

for (const disabledLanguage of this.plugin.loadedSettings.disabledLanguages) {
this.loadedLanguages.delete(disabledLanguage);
}
}

async loadCustomThemes(): Promise<void> {
this.customThemes = [];

// custom themes are disabled unless users specify a folder for them in plugin settings
if (!this.plugin.loadedSettings.customThemeFolder) return;

const themeFolder = normalizePath(this.plugin.loadedSettings.customThemeFolder);
if (!(await this.plugin.app.vault.adapter.exists(themeFolder))) {
new Notice(`${this.plugin.manifest.name}\nUnable to open custom themes folder: ${themeFolder}`, 5000);
return;
}

const themeList = await this.plugin.app.vault.adapter.list(themeFolder);
const themeFiles = themeList.files.filter(f => f.toLowerCase().endsWith('.json'));

for (const themeFile of themeFiles) {
const baseName = themeFile.substring(`${themeFolder}/`.length);
try {
const theme = JSON.parse(await this.plugin.app.vault.adapter.read(themeFile)) as CustomTheme;
// validate that theme file JSON can be parsed and contains colors at a minimum
if (!theme.colors && !theme.tokenColors) {
throw Error('Invalid JSON theme file.');
}
// what metadata is available in the theme file depends on how it was created
theme.displayName = theme.displayName ?? theme.name ?? baseName;
theme.name = baseName.toLowerCase();
theme.type = theme.type ?? 'both';

this.customThemes.push(theme);
} catch (e) {
new Notice(`${this.plugin.manifest.name}\nUnable to load custom theme: ${themeFile}`, 5000);
console.warn(`Unable to load custom theme: ${themeFile}`, e);
}
}

// if the user's set theme cannot be loaded (e.g. it was deleted), fall back to default theme
if (this.usesCustomTheme() && !this.customThemes.find(theme => theme.name === this.plugin.loadedSettings.theme)) {
this.plugin.settings.theme = DEFAULT_SETTINGS.theme;
this.plugin.loadedSettings.theme = DEFAULT_SETTINGS.theme;

await this.plugin.saveSettings();
}

this.customThemes.sort((a, b) => a.displayName.localeCompare(b.displayName));
}

async loadEC(): Promise<void> {
this.ec = new ExpressiveCodeEngine({
themes: [new ExpressiveCodeTheme(await this.themeMapper.getThemeForEC())],
plugins: [
pluginShiki({
langs: Object.values(bundledLanguages),
}),
pluginCollapsibleSections(),
pluginTextMarkers(),
pluginLineNumbers(),
pluginFrames(),
],
styleOverrides: getECTheme(this.plugin.loadedSettings),
minSyntaxHighlightingColorContrast: 0,
themeCssRoot: 'div.expressive-code',
defaultProps: {
showLineNumbers: false,
},
});

this.ecElements = [];

const styles = (await this.ec.getBaseStyles()) + (await this.ec.getThemeStyles());
this.ecElements.push(document.head.createEl('style', { text: styles }));

const jsModules = await this.ec.getJsModules();
for (const jsModule of jsModules) {
this.ecElements.push(document.head.createEl('script', { attr: { type: 'module' }, text: jsModule }));
}
}

unloadEC(): void {
for (const el of this.ecElements) {
el.remove();
}
this.ecElements = [];
}

async loadShiki(): Promise<void> {
this.shiki = await createHighlighter({
themes: [await this.themeMapper.getTheme()],
langs: Object.keys(bundledLanguages),
});
}

usesCustomTheme(): boolean {
return this.plugin.loadedSettings.theme.endsWith('.json');
}

/**
* Highlights code with EC and renders it to the passed container element.
*/
async renderWithEc(code: string, language: string, meta: string, container: HTMLElement): Promise<void> {
const result = await this.ec.render({
code,
language,
meta,
});

container.innerHTML = toHtml(this.themeMapper.fixAST(result.renderedGroupAst));
}
}
19 changes: 16 additions & 3 deletions src/codemirror/Cm6_ViewPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,21 @@ import { Cm6_Util } from 'src/codemirror/Cm6_Util';
import { type ThemedToken } from 'shiki';
import { editorLivePreviewField } from 'obsidian';

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

constructor(view: EditorView) {
this.view = view;
this.decorations = Decoration.none;
this.updateWidgets(view);

plugin.updateCm6Plugin = (): void => {
this.forceUpdate();
};
}

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

// we handle doc changes and selection changes here
if (update.docChanged || update.selectionSet) {
this.view = update.view;
this.updateWidgets(update.view, update.docChanged);
}
}

forceUpdate(): void {
this.updateWidgets(this.view);

this.view.dispatch(this.view.state.update({}));
}

isLivePreview(state: EditorState): boolean {
// @ts-ignore some strange private field not being assignable
return state.field(editorLivePreviewField);
Expand Down
Loading

0 comments on commit 66413a3

Please sign in to comment.