Skip to content

Commit f5d7a71

Browse files
authored
Merge pull request #8 from smcllns/codex/css-theme-tokens
feat: Inject Obsidian theme tokens into HTML docs
2 parents b5dae6a + 16bbac2 commit f5d7a71

6 files changed

Lines changed: 313 additions & 20 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# CSS Theme Tokens
2+
3+
Status: ready for review
4+
5+
Scope:
6+
- [x] Merge docs PR #6 and sync `main`
7+
- [x] Add failing E2E coverage for injected Obsidian theme tokens
8+
- [x] Inject a one-way `:root` theme-token snapshot into HTML iframes
9+
- [x] Apply to tab, markdown embed, and Canvas embed renders
10+
- [x] Re-render open HTML views/embeds when Obsidian theme classes change
11+
- [x] Document the supported `--obsidian-*` CSS contract
12+
- [x] Run release checks and prepare PR
13+
14+
Unresolved questions:
15+
- None.

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ Embed HTML docs like other Obsidian embeds. Embeds default to about 600px tall;
7272
![[doc.html|600x400]]
7373
```
7474

75+
Each iframe receives a one-way snapshot of Obsidian theme styles. HTML docs can use these CSS variables to match light/dark mode, theme colors, and fonts without giving the iframe permission to read Obsidian or the vault. Use fallbacks so files still work outside Obsidian:
76+
77+
```css
78+
:root {
79+
color-scheme: var(--obsidian-color-scheme, light dark);
80+
--bg: var(--obsidian-bg, light-dark(#fff, #0e1014));
81+
--text: var(--obsidian-text, light-dark(#16161a, #e7e9ec));
82+
}
83+
```
84+
85+
Available CSS variables: `--obsidian-color-scheme`, `--obsidian-bg`, `--obsidian-bg-2`, `--obsidian-text`, `--obsidian-text-muted`, `--obsidian-accent`, `--obsidian-border`, `--obsidian-font`, `--obsidian-font-mono`.
86+
7587
## Obsidian Plugin Docs
7688

7789
* Developer docs: [docs.obsidian.md](https://docs.obsidian.md)
@@ -85,7 +97,7 @@ That keeps HTML documents isolated from your notes and Obsidian internals, with
8597
* **Wikilinks to HTML docs need the explicit extension (`[[doc.html]]`).** Extensionless non-Markdown links are not first-class in Obsidian's link index, and click-only plugin handling would leave backlinks and unresolved-link state inconsistent.
8698
* **Links from HTML to Obsidian docs should use Obsidian URI links plus `target="_blank"`.** They hand off to Obsidian without letting the iframe navigate the parent window directly; direct same-tab navigation would require top-navigation sandbox permissions and weaken the boundary.
8799
* **Cookies, `localStorage`, `sessionStorage`, and `IndexedDB` are intentionally unavailable.** Enabling them would require same-origin privileges, which would also let HTML share origin with Obsidian instead of staying isolated.
88-
* **Context must be passed into the iframe, the iframe cannot reach out and pull in.** HTML can load browser-allowed network resources, but it cannot inspect Obsidian, other notes, or local vault files. So for example, the HTML page cannot access Obsidian themes, snippets, vault-relative asset paths, unless they are explicitly passed into the iframe context. Use inline CSS/assets, `data:` URLs, or browser-allowed HTTPS URLs instead.
100+
* **Context must be passed into the iframe, the iframe cannot reach out and pull in.** HTML receives curated theme tokens, but cannot inspect Obsidian, other notes, snippets, or local vault files. Use inline CSS/assets, `data:` URLs, or browser-allowed HTTPS URLs instead of vault-relative asset paths.
89101

90102
## Feedback / Support
91103

docs/handoffs/css-theme-tokens.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# CSS Theme Tokens Handoff
2+
3+
Issue: https://github.com/smcllns/obsidian-plugin-html-docs/issues/5
4+
5+
Implemented shape:
6+
- HTML Docs appends a one-way `<style data-html-docs-theme>` block to each rendered HTML blob's `<head>`.
7+
- The iframe sandbox stays unchanged: `allow-scripts allow-popups allow-forms`, no `allow-same-origin`.
8+
- The stable public CSS contract is:
9+
`--obsidian-color-scheme`, `--obsidian-bg`, `--obsidian-bg-2`, `--obsidian-text`, `--obsidian-text-muted`,
10+
`--obsidian-accent`, `--obsidian-border`, `--obsidian-font`, `--obsidian-font-mono`.
11+
- Source values are read from Obsidian's computed styles and resolved through a temporary parent-side probe so iframe values do not depend on parent-only `var(...)` references.
12+
- Open HTML tabs and embeds re-render when the parent `body` theme class changes or Obsidian emits `css-change`.
13+
- Injection uses `DOMParser` instead of regex so `<head>` text inside comments/scripts is not treated as document structure.
14+
15+
Validation notes:
16+
- `npm test` covers token injection in tab, markdown embed, and Canvas embed modes while preserving cross-origin iframe isolation.
17+
- `npm test` also flips parent `theme-light`/`theme-dark` classes and verifies the open HTML tab re-renders with the opposite injected `color-scheme`.
18+
- A live `obsidian-cli` probe also verified the real Obsidian theme toggle path: injected `color-scheme` changed from `light` to `dark`, then the original light theme was restored.

main.ts

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,94 @@ interface RenderOptions {
3434
heightPx?: number | null;
3535
}
3636

37+
interface ThemeToken {
38+
target: string;
39+
source: string;
40+
probeProperty?: string;
41+
fallback?: (styles: CSSStyleDeclaration) => string;
42+
}
43+
3744
type OpenLinkText = (
3845
linktext: string,
3946
sourcePath: string,
4047
newLeaf?: PaneType | boolean,
4148
openViewState?: OpenViewState,
4249
) => Promise<void>;
4350

51+
const OBSIDIAN_THEME_TOKENS: ThemeToken[] = [
52+
{ target: "--obsidian-bg", source: "--background-primary", probeProperty: "background-color" },
53+
{ target: "--obsidian-bg-2", source: "--background-secondary", probeProperty: "background-color" },
54+
{ target: "--obsidian-text", source: "--text-normal", probeProperty: "color" },
55+
{ target: "--obsidian-text-muted", source: "--text-muted", probeProperty: "color" },
56+
{ target: "--obsidian-accent", source: "--interactive-accent", probeProperty: "background-color" },
57+
{ target: "--obsidian-border", source: "--background-modifier-border", probeProperty: "border-top-color" },
58+
{ target: "--obsidian-font", source: "--font-text", probeProperty: "font-family", fallback: (styles) => styles.fontFamily },
59+
{ target: "--obsidian-font-mono", source: "--font-monospace", probeProperty: "font-family" },
60+
];
61+
4462
function parseDimension(value: string | null): number | null {
4563
if (!value) return null;
4664
const parsed = Number.parseInt(value, 10);
4765
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
4866
}
4967

68+
function getColorScheme(doc: Document): "light" | "dark" {
69+
if (doc.body.classList.contains("theme-dark")) return "dark";
70+
if (doc.body.classList.contains("theme-light")) return "light";
71+
return doc.defaultView?.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
72+
}
73+
74+
function sanitizeCssValue(value: string): string {
75+
const trimmed = value.trim();
76+
if (!trimmed || /[{};]/.test(trimmed) || /<\/style/i.test(trimmed)) return "";
77+
return trimmed.replace(/\s+/g, " ");
78+
}
79+
80+
function resolveThemeToken(doc: Document, styles: CSSStyleDeclaration, token: ThemeToken): string {
81+
const sourceValue = styles.getPropertyValue(token.source).trim();
82+
if (!sourceValue) return sanitizeCssValue(token.fallback?.(styles) ?? "");
83+
if (!token.probeProperty) return sanitizeCssValue(sourceValue);
84+
85+
const probe = doc.createElement("div");
86+
probe.style.setProperty(token.probeProperty, `var(${token.source})`);
87+
doc.body.appendChild(probe);
88+
const resolved = doc.defaultView?.getComputedStyle(probe).getPropertyValue(token.probeProperty) ?? "";
89+
probe.remove();
90+
return sanitizeCssValue(resolved || sourceValue);
91+
}
92+
93+
function buildThemeStyle(doc: Document): string {
94+
const styles = doc.defaultView?.getComputedStyle(doc.body);
95+
if (!styles) throw new Error("HTML Docs: unable to read Obsidian theme styles.");
96+
97+
const colorScheme = getColorScheme(doc);
98+
const declarations = [`color-scheme: ${colorScheme};`, `--obsidian-color-scheme: ${colorScheme};`];
99+
for (const token of OBSIDIAN_THEME_TOKENS) {
100+
const value = resolveThemeToken(doc, styles, token);
101+
if (value) declarations.push(`${token.target}: ${value};`);
102+
}
103+
104+
return `:root {\n\t${declarations.join("\n\t")}\n}`;
105+
}
106+
107+
function serializeDoctype(doctype: DocumentType | null): string {
108+
if (!doctype) return "";
109+
let serialized = `<!doctype ${doctype.name}`;
110+
if (doctype.publicId) serialized += ` PUBLIC "${doctype.publicId}"`;
111+
if (doctype.systemId) serialized += `${doctype.publicId ? "" : " SYSTEM"} "${doctype.systemId}"`;
112+
return `${serialized}>`;
113+
}
114+
115+
function injectThemeStyle(html: string, themeStyle: string): string {
116+
const parsed = new DOMParser().parseFromString(html, "text/html");
117+
const style = parsed.createElement("style");
118+
style.setAttribute("data-html-docs-theme", "");
119+
style.textContent = themeStyle;
120+
parsed.head.appendChild(style);
121+
const doctype = serializeDoctype(parsed.doctype);
122+
return `${doctype ? `${doctype}\n` : ""}${parsed.documentElement.outerHTML}`;
123+
}
124+
50125
function renderSandboxedHtml(contentEl: HTMLElement, html: string, options: RenderOptions): () => void {
51126
contentEl.empty();
52127
contentEl.addClass("html-docs-container");
@@ -61,7 +136,8 @@ function renderSandboxedHtml(contentEl: HTMLElement, html: string, options: Rend
61136
// the page. The sandbox attribute still gives the document an
62137
// opaque origin regardless of URL scheme, so isolation from
63138
// Obsidian and the vault is preserved.
64-
const blobUrl = URL.createObjectURL(new Blob([html], { type: "text/html" }));
139+
const htmlWithTheme = injectThemeStyle(html, buildThemeStyle(contentEl.ownerDocument));
140+
const blobUrl = URL.createObjectURL(new Blob([htmlWithTheme], { type: "text/html" }));
65141

66142
// Build the iframe fully detached so the browser never observes it
67143
// without the sandbox attribute. Inserting first, then setting
@@ -90,6 +166,7 @@ function renderSandboxedHtml(contentEl: HTMLElement, html: string, options: Rend
90166

91167
class HtmlView extends FileView {
92168
private cleanupHtml: (() => void) | null = null;
169+
private renderVersion = 0;
93170

94171
getViewType(): string {
95172
return VIEW_TYPE_HTML;
@@ -104,15 +181,27 @@ class HtmlView extends FileView {
104181
}
105182

106183
async onLoadFile(file: TFile): Promise<void> {
107-
const content = await this.app.vault.cachedRead(file);
108-
this.render(content);
184+
await this.readAndRender(file);
109185
}
110186

111187
async onUnloadFile(_file: TFile): Promise<void> {
188+
this.renderVersion++;
112189
this.cleanupHtml?.();
113190
this.cleanupHtml = null;
114191
}
115192

193+
async refreshTheme(): Promise<void> {
194+
if (!this.file) return;
195+
await this.readAndRender(this.file);
196+
}
197+
198+
private async readAndRender(file: TFile): Promise<void> {
199+
const version = ++this.renderVersion;
200+
const content = await this.app.vault.cachedRead(file);
201+
if (version !== this.renderVersion || this.file !== file || !this.contentEl.isConnected) return;
202+
this.render(content);
203+
}
204+
116205
private render(html: string): void {
117206
this.cleanupHtml?.();
118207
this.cleanupHtml = renderSandboxedHtml(this.contentEl, html, { mode: "view" });
@@ -121,6 +210,8 @@ class HtmlView extends FileView {
121210

122211
class HtmlEmbed extends Component {
123212
private cleanupHtml: (() => void) | null = null;
213+
private renderVersion = 0;
214+
private unloaded = false;
124215

125216
constructor(
126217
private contentEl: HTMLElement,
@@ -131,7 +222,11 @@ class HtmlEmbed extends Component {
131222
}
132223

133224
async loadFile(): Promise<void> {
225+
if (this.unloaded) return;
226+
this.plugin.trackHtmlEmbed(this);
227+
const version = ++this.renderVersion;
134228
const content = await this.plugin.app.vault.cachedRead(this.file);
229+
if (this.unloaded || version !== this.renderVersion) return;
135230
this.cleanupHtml?.();
136231
this.cleanupHtml = renderSandboxedHtml(this.contentEl, content, {
137232
mode: "embed",
@@ -140,20 +235,32 @@ class HtmlEmbed extends Component {
140235
});
141236
}
142237

238+
async refreshTheme(): Promise<void> {
239+
await this.loadFile();
240+
}
241+
143242
onunload(): void {
243+
this.unloaded = true;
244+
this.renderVersion++;
245+
this.plugin.untrackHtmlEmbed(this);
144246
this.cleanupHtml?.();
145247
this.cleanupHtml = null;
146248
}
147249
}
148250

149251
export default class HtmlDocsPlugin extends Plugin {
252+
private readonly htmlEmbeds = new Set<HtmlEmbed>();
253+
private currentColorScheme: "light" | "dark" | null = null;
254+
private themeRefreshTimeout: number | null = null;
255+
150256
async onload(): Promise<void> {
151257
this.registerView(
152258
VIEW_TYPE_HTML,
153259
(leaf: WorkspaceLeaf) => new HtmlView(leaf),
154260
);
155261
this.registerExtensions(["html"], VIEW_TYPE_HTML);
156262
this.registerExistingHtmlTabNavigation();
263+
this.registerThemeRefresh();
157264

158265
const embedRegistry = (this.app as unknown as AppWithEmbedRegistry).embedRegistry;
159266
if (!embedRegistry) {
@@ -175,6 +282,53 @@ export default class HtmlDocsPlugin extends Plugin {
175282
}
176283
}
177284

285+
trackHtmlEmbed(embed: HtmlEmbed): void {
286+
this.htmlEmbeds.add(embed);
287+
}
288+
289+
untrackHtmlEmbed(embed: HtmlEmbed): void {
290+
this.htmlEmbeds.delete(embed);
291+
}
292+
293+
private registerThemeRefresh(): void {
294+
const doc = this.app.workspace.containerEl.ownerDocument;
295+
this.currentColorScheme = getColorScheme(doc);
296+
const refreshIfColorSchemeChanged = () => {
297+
const nextColorScheme = getColorScheme(doc);
298+
if (nextColorScheme === this.currentColorScheme) return;
299+
this.currentColorScheme = nextColorScheme;
300+
this.scheduleThemeRefresh();
301+
};
302+
303+
const observer = new MutationObserver(refreshIfColorSchemeChanged);
304+
observer.observe(doc.body, {
305+
attributes: true,
306+
attributeFilter: ["class"],
307+
});
308+
this.registerEvent(this.app.workspace.on("css-change", () => this.scheduleThemeRefresh()));
309+
this.register(() => {
310+
observer.disconnect();
311+
if (this.themeRefreshTimeout !== null) window.clearTimeout(this.themeRefreshTimeout);
312+
});
313+
}
314+
315+
private scheduleThemeRefresh(): void {
316+
if (this.themeRefreshTimeout !== null) window.clearTimeout(this.themeRefreshTimeout);
317+
this.themeRefreshTimeout = window.setTimeout(() => {
318+
this.themeRefreshTimeout = null;
319+
void this.refreshOpenHtmlDocuments();
320+
}, 50);
321+
}
322+
323+
private async refreshOpenHtmlDocuments(): Promise<void> {
324+
const refreshes: Promise<void>[] = [];
325+
this.app.workspace.iterateAllLeaves((leaf) => {
326+
if (leaf.view instanceof HtmlView) refreshes.push(leaf.view.refreshTheme());
327+
});
328+
for (const embed of this.htmlEmbeds) refreshes.push(embed.refreshTheme());
329+
await Promise.all(refreshes);
330+
}
331+
178332
private registerExistingHtmlTabNavigation(): void {
179333
const workspace = this.app.workspace;
180334
const openLinkText = workspace.openLinkText.bind(workspace) as OpenLinkText;

test/fixture.html

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,27 @@ <h2>Known not to work (by design)</h2>
195195
d.style.cssText = 'height:36px;background:linear-gradient(90deg,#6366f1,#22d3ee,#22c55e);border-radius:6px';
196196
card('render', 'CSS gradients', 'pass', '', d);
197197
})();
198+
(function () {
199+
const root = getComputedStyle(document.documentElement);
200+
const required = ['--obsidian-color-scheme', '--obsidian-bg', '--obsidian-text', '--obsidian-accent', '--obsidian-font'];
201+
const themeStyle = document.querySelector('style[data-html-docs-theme]');
202+
const themeStyleText = themeStyle ? themeStyle.textContent || '' : '';
203+
const missing = required.filter((name) => !root.getPropertyValue(name).trim());
204+
const unresolved = required.filter((name) => root.getPropertyValue(name).includes('var('));
205+
const colorScheme = root.colorScheme.trim();
206+
const demo = document.createElement('div');
207+
demo.style.cssText = 'padding:10px;border:1px solid var(--obsidian-border);background:var(--obsidian-bg);color:var(--obsidian-text);font-family:var(--obsidian-font);border-radius:6px';
208+
demo.textContent = 'Obsidian theme token sample';
209+
const hasInjectedScheme = /color-scheme:\s*(light|dark);/.test(themeStyleText);
210+
const ok = !!themeStyle && missing.length === 0 && unresolved.length === 0 && hasInjectedScheme;
211+
card(
212+
'render',
213+
'Obsidian theme tokens',
214+
ok ? 'pass' : 'fail',
215+
ok ? 'color-scheme: ' + colorScheme : 'missing/unresolved: ' + ([...missing, ...unresolved].join(', ') || 'color-scheme'),
216+
demo,
217+
);
218+
})();
198219
(function () {
199220
const d = document.createElement('div');
200221
d.style.cssText = 'display:flex;gap:8px;align-items:center';

0 commit comments

Comments
 (0)