Skip to content

Commit 8992ee4

Browse files
yolo-samsmcllns
andauthored
feat: Render HTML files in embeds and Canvas (#2)
* feat: Render HTML files in embeds and Canvas * fix: Constrain HTML embed height * docs: Update README overview --------- Co-authored-by: Sam Collins <81678+smcllns@users.noreply.github.com>
1 parent 474cc59 commit 8992ee4

8 files changed

Lines changed: 283 additions & 58 deletions

File tree

README.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
A zero-dependency minimal plugin to enable .html docs inside Obsidian. Inspired by [Thariq's "unreasonable effectiveness of HTML"](https://x.com/trq212/status/2052809885763747935).
44

55

6-
* The HTML is rendered in a sandboxed `<iframe>`.
6+
* HTML is rendered in a sandboxed `<iframe>`, and works across tabs, embeds (`![[doc.html]]`), and Canvas.
77
* JS can run inside the HTML for interactivity but the iframe is isolated from your other notes and Obsidian's own data.
88
* No other bells and whistles.
99

10-
The plugin is ~75 lines of code, ~100 lines of config, ~520 lines of test, and requires no external dependencies.
10+
The plugin is ~190 lines of code, ~660 lines of test, and requires no external dependencies.
1111

1212
## Demo
1313

@@ -43,14 +43,14 @@ npm run build # production bundle at `dist/html-docs/`
4343

4444
## Test
4545

46-
An E2E test runner validates features and sandboxing are working correctly. Requires `obsidian-cli`, Obsidian running with a vault open, the plugin installed and enabled, and `jq` available.
46+
An E2E test runner validates features, embeds, Canvas cards, and sandboxing are working correctly. Requires `obsidian-cli`, Obsidian running with a vault open, the plugin installed and enabled, and `jq` available.
4747

4848

4949
```bash
5050
npm test
5151
```
5252

53-
The script copies `test/fixture.html` into the vault temporarily, opens it in Obsidian, uses `obsidian-cli eval` to inspect the plugin view and verify the iframe exists with the expected sandbox and blob URL settings, collects the iframe’s own self-test results via `postMessage`, then cleans up.
53+
The script builds the current plugin, copies it into the active vault's plugin folder, reloads it, copies `test/fixture.html` into the vault temporarily, opens it in Obsidian, verifies the tab view plus markdown and Canvas embeds, collects the iframe’s own self-test results via `postMessage`, then cleans up.
5454

5555
See `test/fixture.html` for the full list of features exercised — and the inline notes for what is intentionally blocked.
5656

@@ -62,10 +62,6 @@ See `test/fixture.html` for the full list of features exercised — and the inli
6262

6363
This plugin will stay simple and do this one thing well.
6464

65-
File issues here, or message me on X (@smcllns).
65+
File issues here, or message me on X: [@smcllns](https://x.com/smcllns).
6666

6767
If you want more features, please fork and customize as you need.
68-
69-
## Known Issues
70-
71-
1. **Does not support Obsidian Canvas or embeds.** HTML files in Canvas, and embeds in a doc (e.g. `![[doc.html]]`) continue to show the same placeholder as before.

main.ts

Lines changed: 111 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,79 @@
1-
import { FileView, Notice, Plugin, TFile, WorkspaceLeaf } from "obsidian";
1+
import { Component, FileView, Notice, Plugin, TFile, WorkspaceLeaf } from "obsidian";
22

33
const VIEW_TYPE_HTML = "html-docs";
44

5+
interface EmbedContext {
6+
containerEl: HTMLElement;
7+
}
8+
9+
interface EmbedRegistry {
10+
registerExtension(
11+
extension: string,
12+
embedCreator: (context: EmbedContext, file: TFile) => Component,
13+
): void;
14+
unregisterExtension(extension: string): void;
15+
}
16+
17+
interface AppWithEmbedRegistry {
18+
embedRegistry?: EmbedRegistry;
19+
}
20+
21+
interface RenderOptions {
22+
mode: "view" | "embed";
23+
widthPx?: number | null;
24+
heightPx?: number | null;
25+
}
26+
27+
function parseDimension(value: string | null): number | null {
28+
if (!value) return null;
29+
const parsed = Number.parseInt(value, 10);
30+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
31+
}
32+
33+
function renderSandboxedHtml(contentEl: HTMLElement, html: string, options: RenderOptions): () => void {
34+
const previousWidth = contentEl.style.width;
35+
const previousHeight = contentEl.style.height;
36+
37+
contentEl.empty();
38+
contentEl.addClass("html-docs-container");
39+
contentEl.addClass(options.mode === "view" ? "html-docs-view" : "html-docs-embed");
40+
if (options.widthPx) contentEl.style.width = `${options.widthPx}px`;
41+
if (options.heightPx) contentEl.style.height = `${options.heightPx}px`;
42+
43+
// Load the document via a Blob URL rather than srcdoc so anchor
44+
// links (#section) and the History API navigate correctly inside
45+
// the page. The sandbox attribute still gives the document an
46+
// opaque origin regardless of URL scheme, so isolation from
47+
// Obsidian and the vault is preserved.
48+
const blobUrl = URL.createObjectURL(new Blob([html], { type: "text/html" }));
49+
50+
// Build the iframe fully detached so the browser never observes it
51+
// without the sandbox attribute. Inserting first, then setting
52+
// sandbox, leaves a window where the initial about:blank document
53+
// is created with the parent's origin — some Chromium versions
54+
// don't fully re-apply the sandbox on the subsequent navigation,
55+
// leaking same-origin privileges into user HTML.
56+
const iframe = contentEl.ownerDocument.createElement("iframe");
57+
iframe.className = "html-docs-iframe";
58+
// allow-scripts lets the page's JS run; omitting allow-same-origin
59+
// keeps it isolated from Obsidian and the user's vault.
60+
iframe.setAttribute("sandbox", "allow-scripts allow-popups allow-forms");
61+
iframe.src = blobUrl;
62+
contentEl.appendChild(iframe);
63+
64+
return () => {
65+
URL.revokeObjectURL(blobUrl);
66+
contentEl.empty();
67+
contentEl.removeClass("html-docs-container");
68+
contentEl.removeClass("html-docs-view");
69+
contentEl.removeClass("html-docs-embed");
70+
contentEl.style.width = previousWidth;
71+
contentEl.style.height = previousHeight;
72+
};
73+
}
74+
575
class HtmlView extends FileView {
6-
private iframe: HTMLIFrameElement | null = null;
7-
private blobUrl: string | null = null;
76+
private cleanupHtml: (() => void) | null = null;
877

978
getViewType(): string {
1079
return VIEW_TYPE_HTML;
@@ -15,7 +84,7 @@ class HtmlView extends FileView {
1584
}
1685

1786
canAcceptExtension(extension: string): boolean {
18-
return extension === "html" || extension === "htm";
87+
return extension === "html";
1988
}
2089

2190
async onLoadFile(file: TFile): Promise<void> {
@@ -24,45 +93,40 @@ class HtmlView extends FileView {
2493
}
2594

2695
async onUnloadFile(_file: TFile): Promise<void> {
27-
this.revokeBlob();
28-
this.contentEl.empty();
29-
this.iframe = null;
96+
this.cleanupHtml?.();
97+
this.cleanupHtml = null;
3098
}
3199

32-
private revokeBlob(): void {
33-
if (this.blobUrl) {
34-
URL.revokeObjectURL(this.blobUrl);
35-
this.blobUrl = null;
36-
}
100+
private render(html: string): void {
101+
this.cleanupHtml?.();
102+
this.cleanupHtml = renderSandboxedHtml(this.contentEl, html, { mode: "view" });
37103
}
104+
}
38105

39-
private render(html: string): void {
40-
this.contentEl.empty();
41-
this.contentEl.addClass("html-docs-container");
42-
this.revokeBlob();
43-
44-
// Load the document via a Blob URL rather than srcdoc so anchor
45-
// links (#section) and the History API navigate correctly inside
46-
// the page. The sandbox attribute still gives the document an
47-
// opaque origin regardless of URL scheme, so isolation from
48-
// Obsidian and the vault is preserved.
49-
const blob = new Blob([html], { type: "text/html" });
50-
this.blobUrl = URL.createObjectURL(blob);
51-
52-
// Build the iframe fully detached so the browser never observes it
53-
// without the sandbox attribute. Inserting first, then setting
54-
// sandbox, leaves a window where the initial about:blank document
55-
// is created with the parent's origin — some Chromium versions
56-
// don't fully re-apply the sandbox on the subsequent navigation,
57-
// leaking same-origin privileges into user HTML.
58-
const iframe = activeDocument.createElement("iframe");
59-
iframe.className = "html-docs-iframe";
60-
// allow-scripts lets the page's JS run; omitting allow-same-origin
61-
// keeps it isolated from Obsidian and the user's vault.
62-
iframe.setAttribute("sandbox", "allow-scripts allow-popups allow-forms");
63-
iframe.src = this.blobUrl;
64-
this.contentEl.appendChild(iframe);
65-
this.iframe = iframe;
106+
class HtmlEmbed extends Component {
107+
private cleanupHtml: (() => void) | null = null;
108+
109+
constructor(
110+
private contentEl: HTMLElement,
111+
private plugin: HtmlDocsPlugin,
112+
private file: TFile,
113+
) {
114+
super();
115+
}
116+
117+
async loadFile(): Promise<void> {
118+
const content = await this.plugin.app.vault.cachedRead(this.file);
119+
this.cleanupHtml?.();
120+
this.cleanupHtml = renderSandboxedHtml(this.contentEl, content, {
121+
mode: "embed",
122+
widthPx: parseDimension(this.contentEl.getAttribute("width")),
123+
heightPx: parseDimension(this.contentEl.getAttribute("height")),
124+
});
125+
}
126+
127+
onunload(): void {
128+
this.cleanupHtml?.();
129+
this.cleanupHtml = null;
66130
}
67131
}
68132

@@ -72,7 +136,14 @@ export default class HtmlDocsPlugin extends Plugin {
72136
VIEW_TYPE_HTML,
73137
(leaf: WorkspaceLeaf) => new HtmlView(leaf),
74138
);
75-
this.registerExtensions(["html", "htm"], VIEW_TYPE_HTML);
139+
this.registerExtensions(["html"], VIEW_TYPE_HTML);
140+
141+
const embedRegistry = (this.app as unknown as AppWithEmbedRegistry).embedRegistry;
142+
if (!embedRegistry) {
143+
throw new Error("HTML Docs: app.embedRegistry is unavailable; cannot register HTML embeds.");
144+
}
145+
embedRegistry.registerExtension("html", (context, file) => new HtmlEmbed(context.containerEl, this, file));
146+
this.register(() => embedRegistry.unregisterExtension("html"));
76147

77148
// Obsidian hides files with unrecognized extensions in the file
78149
// explorer unless "Show all file types" is on; registering the

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "html-docs",
33
"name": "HTML Docs",
4-
"version": "1.0.4",
4+
"version": "1.1.0",
55
"minAppVersion": "1.4.0",
66
"description": "A zero-dependency minimal plugin to enable .html docs inside Obsidian. Inspired by Anthropic's 'unreasonable effectiveness of HTML'",
77
"author": "smcllns",

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "html-docs",
3-
"version": "1.0.4",
3+
"version": "1.1.0",
44
"description": "A zero-dependency minimal plugin to enable .html docs inside Obsidian. Inspired by Anthropic's 'unreasonable effectiveness of HTML'",
55
"main": "main.js",
66
"scripts": {

styles.css

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
.html-docs-container {
22
padding: 0;
3-
height: 100%;
43
width: 100%;
54
}
65

6+
.html-docs-view,
7+
.canvas-node-content.html-docs-embed {
8+
height: 100%;
9+
}
10+
11+
.html-docs-embed {
12+
max-width: 100%;
13+
}
14+
15+
.markdown-rendered .html-docs-embed,
16+
.markdown-source-view .html-docs-embed {
17+
height: var(--html-docs-embed-height, 600px);
18+
max-height: var(--embed-max-height);
19+
overflow: hidden;
20+
}
21+
722
.html-docs-iframe {
823
width: 100%;
924
height: 100%;

0 commit comments

Comments
 (0)