Skip to content

Commit 06daaeb

Browse files
committed
improve support for custom themes
1 parent 6043623 commit 06daaeb

File tree

7 files changed

+79
-56
lines changed

7 files changed

+79
-56
lines changed

.github/workflows/release.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ jobs:
2626
bun install
2727
bun run build
2828
mkdir ${{ env.PLUGIN_NAME }}
29-
mkdir ${{ env.PLUGIN_NAME }}/themes
30-
touch ${{ env.PLUGIN_NAME }}/themes/place custom themes here
3129
cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }}
3230
zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }}
3331
ls

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ This feature can be turned off in the settings.
3434

3535
### Custom Themes
3636

37-
This plugin comes with a [wide variety of themes](https://expressive-code.com/guides/themes/#using-bundled-themes) bundled in. In addition, it supports custom JSON theme files compatible with VS Code. Simply place your custom JSON theme files in the plugin's `themes` folder, which can be accessed from the settings. The custom themes will show up in the Theme dropdown after restarting Obsidian.
37+
This plugin comes bundled with a [wide variety of themes](https://expressive-code.com/guides/themes/#using-bundled-themes). In addition, it supports custom JSON theme files compatible with VS Code. To enable custom themes, create a folder containing your theme files, and specify the folder's path relative to your Vault in the plugin settings. After restarting Obsidian, your custom themes will be available in the Theme dropdown.
3838

3939
## Code Block Configuration
4040

src/main.ts

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { loadPrism, Plugin, TFile } from 'obsidian';
1+
import { loadPrism, Plugin, TFile, Notice, normalizePath } from 'obsidian';
22
import { bundledLanguages, getHighlighter, type ThemedToken, type Highlighter, type TokensResult } from 'shiki';
33
import { ExpressiveCodeEngine, ExpressiveCodeTheme } from '@expressive-code/core';
44
import { pluginShiki } from '@expressive-code/plugin-shiki';
@@ -16,11 +16,12 @@ import { LoadedLanguage } from 'src/LoadedLanguage';
1616
import { getECTheme } from 'src/themes/ECTheme';
1717

1818
interface CustomTheme {
19-
id: string;
19+
name: string;
2020
displayName: string;
2121
type: string;
22-
jsonData: Record<string, unknown>;
23-
}
22+
colors?: Record<string, unknown>[];
23+
tokenColors?: Record<string, unknown>[];
24+
}
2425

2526
// some languages break obsidian's `registerMarkdownCodeBlockProcessor`, so we blacklist them
2627
const languageNameBlacklist = new Set(['c++', 'c#', 'f#', 'mermaid']);
@@ -48,8 +49,8 @@ export default class ShikiPlugin extends Plugin {
4849
customThemes: CustomTheme[] = [];
4950

5051
async onload(): Promise<void> {
51-
await this.loadCustomThemes();
5252
await this.loadSettings();
53+
await this.loadCustomThemes();
5354

5455
this.loadedSettings = structuredClone(this.settings);
5556

@@ -90,35 +91,41 @@ export default class ShikiPlugin extends Plugin {
9091
}
9192

9293
async loadCustomThemes(): Promise<void> {
94+
// custom themes are disabled unless users specify a folder for them in plugin settings
95+
if (!this.settings.customThemeFolder) return;
9396

94-
// @ts-expect-error TS2339
95-
const themePath = this.app.vault.adapter.path.join(this.app.vault.configDir, 'plugins', this.manifest.id, 'themes');
97+
const themeFolder = normalizePath(this.settings.customThemeFolder);
98+
if (!(await this.app.vault.adapter.exists(themeFolder))) {
99+
new Notice(`${this.manifest.name}\nUnable to open custom themes folder: ${themeFolder}`, 5000);
100+
return;
101+
}
96102

97-
if (! await this.app.vault.adapter.exists(themePath)) {
98-
console.warn(`Path to custom themes does not exist: ${themePath}`);
99-
} else {
100-
const themeList = await this.app.vault.adapter.list(themePath);
101-
const themeFiles = themeList.files.filter(f => f.toLowerCase().endsWith('.json'));
102-
103-
for (let themeFile of themeFiles) {
104-
try {
105-
// not all theme files have proper metadata; some contain invalid JSON
106-
const theme = JSON.parse(await this.app.vault.adapter.read(themeFile));
107-
const baseName = themeFile.substring(`${themePath}/`.length);
108-
const displayName = theme.displayName ?? theme.name ?? baseName;
109-
theme.name = baseName;
110-
theme.type = theme.type ?? 'both';
111-
this.customThemes.push({
112-
id: baseName,
113-
displayName: displayName,
114-
type: theme.type,
115-
jsonData: theme
116-
});
117-
} catch(err) {
118-
console.warn(`Unable to load custom theme file: ${themeFile}`, err);
103+
const themeList = await this.app.vault.adapter.list(themeFolder);
104+
const themeFiles = themeList.files.filter(f => f.toLowerCase().endsWith('.json'));
105+
106+
for (const themeFile of themeFiles) {
107+
const baseName = themeFile.substring(`${themeFolder}/`.length);
108+
try {
109+
// validate that theme file JSON can be parsed and contains colors at a minimum
110+
const theme = JSON.parse(await this.app.vault.adapter.read(themeFile)) as CustomTheme;
111+
if (!theme.colors && !theme.tokenColors) {
112+
throw Error('Invalid JSON theme file.');
119113
}
114+
// what metadata is available in the theme file depends on how it was created
115+
theme.displayName = theme.displayName ?? theme.name ?? baseName;
116+
theme.name = baseName;
117+
theme.type = theme.type ?? 'both';
118+
this.customThemes.push(theme);
119+
} catch (e) {
120+
new Notice(`${this.manifest.name}\nUnable to load custom theme: ${themeFile}`, 5000);
121+
console.warn(`Unable to load custom theme: ${themeFile}`, e);
120122
}
121123
}
124+
125+
// if the user's set theme cannot be loaded (e.g. it was deleted), fall back to default theme
126+
if (!this.customThemes.find(theme => theme.name === this.settings.theme)) {
127+
this.settings.theme = DEFAULT_SETTINGS.theme;
128+
}
122129
}
123130

124131
async loadLanguages(): Promise<void> {

src/obsidian-ex.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export {};
2+
3+
declare module 'obsidian' {
4+
interface App {
5+
// opens a file or folder with the default application
6+
openWithDefaultApp(path: string): void;
7+
}
8+
}

src/settings/Settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
export interface Settings {
22
disabledLanguages: string[];
3+
customThemeFolder: string;
34
theme: string;
45
preferThemeColors: boolean;
56
inlineHighlighting: boolean;
67
}
78

89
export const DEFAULT_SETTINGS: Settings = {
910
disabledLanguages: [],
11+
customThemeFolder: '',
1012
theme: 'obsidian-theme',
1113
preferThemeColors: true,
1214
inlineHighlighting: true,

src/settings/SettingsTab.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PluginSettingTab, Setting, Platform } from 'obsidian';
1+
import { PluginSettingTab, Setting, Platform, Notice, normalizePath } from 'obsidian';
22
import type ShikiPlugin from 'src/main';
33
import { StringSelectModal } from 'src/settings/StringSelectModal';
44
import { bundledThemesInfo } from 'shiki';
@@ -18,12 +18,12 @@ export class ShikiSettingsTab extends PluginSettingTab {
1818
// sort custom themes by their display name
1919
this.plugin.customThemes.sort((a, b) => a.displayName.localeCompare(b.displayName));
2020

21-
const customThemes = Object.fromEntries(this.plugin.customThemes.map(theme => [theme.id, `${theme.displayName} (${theme.type})`]));
21+
const customThemes = Object.fromEntries(this.plugin.customThemes.map(theme => [theme.name, `${theme.displayName} (${theme.type})`]));
2222
const builtInThemes = Object.fromEntries(bundledThemesInfo.map(theme => [theme.id, `${theme.displayName} (${theme.type})`]));
2323
const themes = {
2424
'obsidian-theme': 'Obsidian built-in (both)',
2525
...customThemes,
26-
...builtInThemes
26+
...builtInThemes,
2727
};
2828

2929
new Setting(this.containerEl)
@@ -39,15 +39,31 @@ export class ShikiSettingsTab extends PluginSettingTab {
3939

4040
if (Platform.isDesktopApp) {
4141
new Setting(this.containerEl)
42-
.setName('Custom themes')
43-
.setDesc('Click to open the folder where you can add your custom JSON theme files. RESTART REQUIRED AFTER CHANGES.')
44-
.addButton(button => {
45-
button.setButtonText('Custom themes...').onClick(() => {
46-
// @ts-expect-error TS2339
47-
const themePath = this.plugin.app.vault.adapter.path.join(this.plugin.app.vault.configDir, 'plugins', this.plugin.manifest.id, 'themes');
48-
// @ts-expect-error TS2339
49-
this.plugin.app.openWithDefaultApp(themePath);
50-
});
42+
.setName('Custom themes folder location')
43+
.setDesc('Folder relative to your Vault where custom JSON theme files are located. RESTART REQUIRED AFTER CHANGES.')
44+
.addText(textbox => {
45+
textbox
46+
.setValue(this.plugin.settings.customThemeFolder)
47+
.onChange(async value => {
48+
this.plugin.settings.customThemeFolder = value;
49+
await this.plugin.saveSettings();
50+
})
51+
.then(textbox => {
52+
textbox.inputEl.style.width = '250px';
53+
});
54+
})
55+
.addExtraButton(button => {
56+
button
57+
.setIcon('folder-open')
58+
.setTooltip('Open custom themes folder')
59+
.onClick(async () => {
60+
const themeFolder = normalizePath(this.plugin.settings.customThemeFolder);
61+
if (await this.app.vault.adapter.exists(themeFolder)) {
62+
this.plugin.app.openWithDefaultApp(themeFolder);
63+
} else {
64+
new Notice(`Unable to open custom themes folder: ${themeFolder}`, 5000);
65+
}
66+
});
5167
});
5268
}
5369

src/themes/ThemeMapper.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,8 @@ export class ThemeMapper {
1818

1919
async getThemeForEC(): Promise<ThemeRegistration> {
2020
if (this.plugin.loadedSettings.theme.toLowerCase().endsWith('.json')) {
21-
let theme = this.plugin.customThemes.find(theme => theme.id === this.plugin.loadedSettings.theme);
22-
if (theme?.jsonData) {
23-
return theme.jsonData;
24-
}
25-
}
26-
else if (this.plugin.loadedSettings.theme !== 'obsidian-theme') {
21+
return this.plugin.customThemes.find(theme => theme.name === this.plugin.loadedSettings.theme) as ThemeRegistration;
22+
} else if (this.plugin.loadedSettings.theme !== 'obsidian-theme') {
2723
return (await bundledThemes[this.plugin.loadedSettings.theme as BundledTheme]()).default;
2824
}
2925

@@ -50,12 +46,8 @@ export class ThemeMapper {
5046

5147
async getTheme(): Promise<ThemeRegistration> {
5248
if (this.plugin.loadedSettings.theme.toLowerCase().endsWith('.json')) {
53-
let theme = this.plugin.customThemes.find(theme => theme.id === this.plugin.loadedSettings.theme);
54-
if (theme?.jsonData) {
55-
return theme.jsonData;
56-
}
57-
}
58-
else if (this.plugin.loadedSettings.theme !== 'obsidian-theme') {
49+
return this.plugin.customThemes.find(theme => theme.name === this.plugin.loadedSettings.theme) as ThemeRegistration;
50+
} else if (this.plugin.loadedSettings.theme !== 'obsidian-theme') {
5951
return (await bundledThemes[this.plugin.loadedSettings.theme as BundledTheme]()).default;
6052
}
6153

0 commit comments

Comments
 (0)