Obsidian Plugin To Convert Notes To Blog Friendly HTML
TL;DR
I built a custom Obsidian plugin that converts notes to blog-ready HTML in one click, handling frontmatter stripping, Obsidian-specific syntax, code blocks, callouts, and Mermaid diagrams, so I can paste directly into
Squarespace without manual tweaking.
The aim
I like plain text/markdown as a way of keeping notes but found I needed to tweak the markdown to get it to display things the way I wanted on the blog page and honestly I don't have the time for that, so I do what all lazy engineers do (the only good type of engineer btw) I automated most of it. This post covers me building out a custom plugin for Obsidian to convert a note into something I can mostly just cut and paste into my blog. The whole thing took about half a day to put together.
Getting setup
I opened the git bash terminal in VSCode, created my new directory, then opened it in the terminal and opened it in VSCode.
mkdir /i/obs_notetoSnippet
cd /i/obs_notetoSnippet
The sample plugin already has the boilerplate needed at https://github.com/obsidianmd/obsidian-sample-plugin so I cloned that to my folder
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
Next to test it I created a folder inside of my Obsidian plugins folder.
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
Then to make sure when I build the plugin in VSCode it goes into that directory I edited the esbuild.config.mjs file and changed the value of outfile from "main.js" to "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet/main.js" this allows the output to be written directly to the plugins folder for testing inside Obsidian, I also added code to the file to copy across the manifest.json file and the styles.css file every time the project gets built.
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from 'node:module';
import { copyFileSync } from 'fs';
const PLUGIN_DIR = "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet";
const copyFilesPlugin = {
name: "copy-files",
setup(build) {
build.onEnd(() => {
copyFileSync("styles.css", `${PLUGIN_DIR}/styles.css`);
copyFileSync("manifest.json", `${PLUGIN_DIR}/manifest.json`);
});
},
};
//...EXISTING CODE ...
outfile: "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet/main.js",
minify: prod,
plugins: [copyFilesPlugin],
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
I ran npm run dev and confirmed the files were copied into my output plugins folder.
$ npm run dev
> obsidian-sample-plugin@1.0.0 dev
> node esbuild.config.mjs
[watch] build finished, watching for changes...
Happy days hard part over :D (I joke)
Testing the sample
This is pretty much straight from the git hub for the sample plugin but testing was a case of Opening Obsidian going to Settings → Community plugins → Turn off restricted mode clicking Installed plugins, and toggling the "Sample Plugin" on.
The sample creates a little dice icon in the ribbon bar in Obsidian that when clicked, displays a test message.
So I've confirmed I have a working if not very useful plugin.
Adding functionality
So the plan was for this to be a handy tool for converting a note in Obsidian into a html snippet I could drop into my Squarespace blog, possibly the very one you are reading this on. So I need to start getting the functionality built.
Step 1 Add scaffolding and rename the plugin
This step cleans up the sample's boilerplate.
- Updated the
manifest.jsonto give it my own id, name, description, author values as well as settingisDesktopOnly: trueI don't use obsidian on a mobile device so just targeting desktop is fine.
{
"id": "noteToHTMLSnippet",
"name": "Note to HTML Snippet",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.",
"author": "MartynMakes",
"authorUrl": "https://www.martynmakes.co.uk",
"isDesktopOnly": true
}
Update the
settings.tsfile this file sets out what the settings are and their types and lets the rest of the code know what to expect. It also defines what settings are available when the user looks at the settings in the plugins tab.I keep settings in their own file rather than bundling them into
main.ts, it keepsmain.tsfocused on what the plugin actually does rather than how it stores its config, and if I ever add more settings I'm not hunting around to find them.
// Pull in the three Obsidian classes we need:
// App the top-level Obsidian application object (passed to every plugin/tab)
// PluginSettingTab base class that Obsidian calls to render a plugin's settings page
// Setting builder that renders a single labelled row in the settings UI
import { App, PluginSettingTab, Setting } from "obsidian";
// Import our plugin class so the settings tab can call saveSettings() on it.
// This is a circular-looking import but TypeScript/esbuild handles it fine because
// we only use the type at compile time and the instance at runtime.
import SquarespaceExportPlugin from "./main";
// The shape of our saved settings object.
// Obsidian serialises this to JSON in the plugin's data.json file automatically.
export interface SquarespaceExportSettings {
outputFolder: string; // vault-relative folder where .html files are written
embedCss: boolean; // whether to inline the CSS block in the exported file
openAfterExport: boolean; // whether to open the exported file in Obsidian immediately
}
// The values used on first install, or if a setting key is missing from saved data.
// Object.assign(DEFAULT_SETTINGS, savedData) in main.ts ensures new keys always have a fallback.
export const DEFAULT_SETTINGS: SquarespaceExportSettings = {
outputFolder: "squarespace-exports",
embedCss: true,
openAfterExport: false,
};
// PluginSettingTab is the Obsidian base class for the settings page.
// Obsidian calls display() every time the user opens this plugin's settings tab.
export class SquarespaceExportSettingTab extends PluginSettingTab {
plugin: SquarespaceExportPlugin; // reference back to the plugin so we can read/write settings
constructor(app: App, plugin: SquarespaceExportPlugin) {
super(app, plugin); // required passes app and plugin to the Obsidian base class
this.plugin = plugin;
}
display(): void {
const { containerEl } = this; // the DOM element Obsidian gives us to render into
containerEl.empty(); // clear any previously rendered content before re-drawing
// --- Output folder setting ---
// addText renders a text input. setPlaceholder shows grey hint text when empty.
// onChange fires on every keystroke we save immediately so nothing is lost.
new Setting(containerEl)
.setName("Output folder")
.setDesc("Vault folder where exported HTML files are saved.")
.addText(text => text
.setPlaceholder("squarespace-exports")
.setValue(this.plugin.settings.outputFolder)
.onChange(async (value) => {
this.plugin.settings.outputFolder = value;
await this.plugin.saveSettings();
}));
// --- Embed CSS toggle ---
// addToggle renders an on/off switch.
new Setting(containerEl)
.setName("Embed CSS")
.setDesc("Include styles in the exported HTML.")
.addToggle(toggle => toggle
.setValue(this.plugin.settings.embedCss)
.onChange(async (value) => {
this.plugin.settings.embedCss = value;
await this.plugin.saveSettings();
}));
// --- Open after export toggle ---
new Setting(containerEl)
.setName("Open after export")
.setDesc("Open the exported file in Obsidian after saving.")
.addToggle(toggle => toggle
.setValue(this.plugin.settings.openAfterExport)
.onChange(async (value) => {
this.plugin.settings.openAfterExport = value;
await this.plugin.saveSettings();
}));
}
}
[!NOTE]
I know it tends to go against the idea of clean code but I like comments, many many comments. From day to day I could be working onC#,python,javascript,micropython... the list goes on and trying to keep all of that stuffed into my last two functioning braincells just isn't possible. So being able to use a tool like Claude or Co-pilot to annotate my code is a godsend especially as it may be many weeks before I revisit it.
Step 2 Adding a Command to read an active note
In this step I added the placeholder code that will become the core of the plugin. main.ts, as the name suggests, is the main file and entry point of the plugin. Mine has five methods:
- onload is the functionality triggered when the plugin gets enabled like adding the settings tab for the plugin, adding the icon in the ribbon bar, and registering a command in Obsidian's command palette.
- onunload is the clean-up step that happens when the plugin .... (wait for it) .... unloads.
- loadSettings loads any saved settings from the
data.jsonObsidian stores them in and if a setting is not saved it picks up the default from thesettings.tsI already modified. - saveSettings does what it says right on the tin. We've got no interest in doing anything fancy for now so it's just a straight call to the Obsidian save data method.
- exportActiveNote this is the meat of the plugin and will handle the conversion from md to HTML, for now I have it just set up to create the output file and folder and dump the raw file into it. My aim is to be able to test every step as I complete it, so this functionality will do for now.
Splitting into five methods keeps things clean, onload and onunload are required Obsidian lifecycle hooks, loadSettings and saveSettings are boilerplate, and exportActiveNote is where meat of the plugin lives. Keeping them separate allows me to stub out exportActiveNote in step 2 and fill it in incrementally.
// Plugin Obsidian's base class for all plugins; provides this.app, this.addCommand(), etc.
// Notice shows a small toast notification in the Obsidian UI
// normalizePath converts any path string to Obsidian's internal format (forward slashes, no leading slash)
import { Plugin, Notice, normalizePath } from 'obsidian';
// Import our settings type, defaults, and the settings tab UI class from settings.ts
import { DEFAULT_SETTINGS, SquarespaceExportSettings, SquarespaceExportSettingTab } from "./settings";
// The main plugin class. Obsidian instantiates this automatically when the plugin is enabled.
// "export default" is required Obsidian looks for the default export as the plugin entry point.
export default class SquarespaceExportPlugin extends Plugin {
settings: SquarespaceExportSettings; // holds the current settings values in memory
// onload() is called by Obsidian when the plugin is enabled.
// Everything the plugin registers here (commands, tabs, icons) is automatically
// cleaned up by Obsidian when the plugin is disabled no manual teardown needed.
async onload() {
await this.loadSettings(); // load saved settings before anything else uses them
// Register the settings tab appears under Settings → Community plugins → Squarespace Export
this.addSettingTab(new SquarespaceExportSettingTab(this.app, this));
// Add an icon to the left ribbon bar. "code-2" is a Lucide icon name.
// Clicking it calls exportActiveNote().
this.addRibbonIcon("code-2", "Export to Squarespace HTML", () => this.exportActiveNote());
// Register a command palette entry (Ctrl+P → "Export to Squarespace HTML").
// id must be unique across all plugins; name is what the user sees.
this.addCommand({
id: "export-current-note",
name: "Export to Squarespace HTML",
callback: () => this.exportActiveNote(),
});
}
// onunload() is called when the plugin is disabled. Nothing to clean up manually here
// because Obsidian handles everything registered via addCommand/addRibbonIcon/addSettingTab.
onunload() {}
// Reads saved data from the plugin's data.json file in the vault.
// Object.assign merges in this order: empty object ← defaults ← saved data.
// This means saved values win, but any missing keys fall back to DEFAULT_SETTINGS.
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<SquarespaceExportSettings>);
}
// Writes the current settings object to data.json.
// Called by the settings tab every time the user changes a value.
async saveSettings() {
await this.saveData(this.settings);
}
// The main export function currently writes the raw note content to an .html file.
// Future steps will transform the content before writing.
async exportActiveNote() {
// getActiveFile() returns the currently open note, or null if nothing is open.
const file = this.app.workspace.getActiveFile();
// Guard: bail out if there's no active file or it isn't a markdown note.
if (!file || file.extension !== "md") {
new Notice("No active note.");
return;
}
// Read the full raw markdown text of the note.
const source = await this.app.vault.read(file);
// Build the output filename from the note's name (without extension).
const filename = file.basename + ".html";
// normalizePath ensures the path is valid inside the vault regardless of OS.
const outPath = normalizePath(this.settings.outputFolder + "/" + filename);
// Create the output folder if it doesn't already exist. Safe to call if it exists.
await this.app.vault.adapter.mkdir(normalizePath(this.settings.outputFolder));
// Write the file. At this stage the content is still raw markdown
// later steps will convert it to HTML before this line.
await this.app.vault.adapter.write(outPath, source);
// Notify the user where the file was saved.
new Notice("Exported → " + outPath);
}
}
The last thing to do here was test
- Reload Obsidian (there is a command in the palette to avoid shutting it down each time)
- Open a note in Obsidian
- Run Ctrl+P → Note To HTML Snippet: Export to Squarespace HTML (or click the ribbon icon)
- Check the vault for a
squarespace-exportsfolder containing a.htmlfile with the raw markdown content
Exporting works, on to the conversion.
Step 3 Stripping out the frontmatter
In my vault I tend to use frontmatter to help organise the notes and store additional details and tags. The frontmatter is YAML formatted and as I only want the content of the note, not the knowledge-base-specific attributes, this needs to be stripped as part of the conversion. If I didn't strip it, every exported post would open with a block of raw YAML which is not a great look.
In main.ts I added a stripFrontmatter function that the exportActiveNote method can call. The function looks for the three dashes that start the frontmatter and the three that close it and removes them and anything in between.
// Strips the YAML frontmatter block (between --- delimiters) from the top of a note.
// Returns the body only, with leading blank lines removed.
function stripFrontmatter(source: string): string {
if (!source.startsWith("---\n")) return source;
const end = source.indexOf("\n---", 4);
if (end === -1) return source;
// slice(end + 4) discards everything up to and including the closing "\n---".
// replace(/^\n+/, "") strips any blank lines at the top of the remaining body.
return source.slice(end + 4).replace(/^\n+/, "");
}
I tested it by triggering the plugin and checking that the output no longer contained the frontmatter.
Step 4 adding in marked
// Pull in the three Obsidian classes we need:
// App the top-level Obsidian application object (passed to every plugin/tab)
// PluginSettingTab base class that Obsidian calls to render a plugin's settings page
// Setting builder that renders a single labelled row in the settings UI
import { App, PluginSettingTab, Setting } from "obsidian";
// Import our plugin class so the settings tab can call saveSettings() on it.
// This is a circular-looking import but TypeScript/esbuild handles it fine because
// we only use the type at compile time and the instance at runtime.
import SquarespaceExportPlugin from "./main";
// The shape of our saved settings object.
// Obsidian serialises this to JSON in the plugin's data.json file automatically.
export interface SquarespaceExportSettings {
outputFolder: string; // vault-relative folder where .html files are written
embedCss: boolean; // whether to inline the CSS block in the exported file
openAfterExport: boolean; // whether to open the exported file in Obsidian immediately
}
// The values used on first install, or if a setting key is missing from saved data.
// Object.assign(DEFAULT_SETTINGS, savedData) in main.ts ensures new keys always have a fallback.
export const DEFAULT_SETTINGS: SquarespaceExportSettings = {
outputFolder: "squarespace-exports",
embedCss: true,
openAfterExport: false,
};
// PluginSettingTab is the Obsidian base class for the settings page.
// Obsidian calls display() every time the user opens this plugin's settings tab.
export class SquarespaceExportSettingTab extends PluginSettingTab {
plugin: SquarespaceExportPlugin; // reference back to the plugin so we can read/write settings
constructor(app: App, plugin: SquarespaceExportPlugin) {
super(app, plugin); // required passes app and plugin to the Obsidian base class
this.plugin = plugin;
}
display(): void {
const { containerEl } = this; // the DOM element Obsidian gives us to render into
containerEl.empty(); // clear any previously rendered content before re-drawing
// --- Output folder setting ---
// addText renders a text input. setPlaceholder shows grey hint text when empty.
// onChange fires on every keystroke we save immediately so nothing is lost.
new Setting(containerEl)
.setName("Output folder")
.setDesc("Vault folder where exported HTML files are saved.")
.addText(text => text
.setPlaceholder("squarespace-exports")
.setValue(this.plugin.settings.outputFolder)
.onChange(async (value) => {
this.plugin.settings.outputFolder = value;
await this.plugin.saveSettings();
}));
// --- Embed CSS toggle ---
// addToggle renders an on/off switch.
new Setting(containerEl)
.setName("Embed CSS")
.setDesc("Include styles in the exported HTML.")
.addToggle(toggle => toggle
.setValue(this.plugin.settings.embedCss)
.onChange(async (value) => {
this.plugin.settings.embedCss = value;
await this.plugin.saveSettings();
}));
// --- Open after export toggle ---
new Setting(containerEl)
.setName("Open after export")
.setDesc("Open the exported file in Obsidian after saving.")
.addToggle(toggle => toggle
.setValue(this.plugin.settings.openAfterExport)
.onChange(async (value) => {
this.plugin.settings.openAfterExport = value;
await this.plugin.saveSettings();
}));
}
}
Marked is a Markdown to HTML converter that is fast and takes care of a lot of the work for me. Without it I'd probably have to write a whole parser from scratch https://marked.js.org/
There are a few markdown parsers but this one has been around a long time, has GitHub Flavoured Markdown support that's roughly what Obsidian uses, and it didn't require me to configure a custom renderer from scratch.
First I installed it npm install marked then I got to wiring it up.
import { marked } from 'marked'; // imports marked into main.ts
//...existing code ...//
// The main export function currently writes the raw note content to an .html file.
// Future steps will transform the content before writing.
async exportActiveNote() {
//...existing code ...//
// Read the full raw markdown text of the note.
const source = stripFrontmatter(await this.app.vault.read(file));
const html = await marked.parse(source, { gfm: true, breaks: true });
// Build the output filename from the note's name (without extension).
const filename = file.basename + ".html";
// normalizePath ensures the path is valid inside the vault regardless of OS.
const outPath = normalizePath(this.settings.outputFolder + "/" + filename);
// Create the output folder if it doesn't already exist. Safe to call if it exists.
await this.app.vault.adapter.mkdir(normalizePath(this.settings.outputFolder));
// Write the file. At this stage the content is still raw markdown
// later steps will convert it to HTML before this line.
await this.app.vault.adapter.write(outPath, html);
// Notify the user where the file was saved.
new Notice("Exported → " + outPath);
}
A quick test in Obsidian confirms all is working, no more nasty wall of text.
Step 5 dealing with Wikilinks, comments, highlights and tags
I only really use the Wikilinks and inline tags but I figured it was worth allowing for the comments and highlights in case I decided to start using them in the future.
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
Expected output will be:
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
This transformation will be done using a few regex functions and a sentinel pattern. The sentinel approach might look over-engineered but it's actually the simplest solution over running all the converters on the raw text and trying to write each in a way that ignores code blocks, it would mean every single converter has to understand code block syntax so instead the code replaces the blocks with a placeholder eg.
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
and then puts the substituted text back in later.
One issue that plagued me when testing with a longer note was indented code fences, the original regex extractor only caught un-indented fences so indented ones slipped through and got mangled. I also hit an issue restoring the raw fence delimiters before calling marked.parse. Marked to render them as literal text inside <p> tags instead of treating them as code blocks. After trouble shooting with copilot for more time than I wanted the fix was to switch the extractor to a line-by-line parser that accepts leading whitespace, and to change the restoration step so it runs after marked.parse converting sentinels directly to <pre><code> HTML rather than putting markdown fences back into the body first.
The order matters here. preprocessCodeFilenames still runs before marked because marked strips the title: annotation from fence info strings. But the actual code block content is now injected as HTML after marked has done its work.
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
So after the stripFrontmatter function I added:
extractCodeBlocksthe sentinel that will prevent code blocks getting mangledrestoreCodeBlocksHtmlconverts sentinels directly to HTML<pre><code>aftermarked.parseruns, rather than putting raw fence markers back before itconvertWikilinksconverts wikilinks to html linksconvertObsidianCommentsstrips out commentsconvertHighlightsreplaces the highlights with the html<mark/>elementconvertTagsreplaces the tags with an<em/>element
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
To wire that in I updated exportActiveNote():
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
Because I don't use some of these elements I created a markdown file to test against:
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
The test ran fine and the html looks good:
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
Step 6 handling Callouts
Callouts are something I make a lot of use of in my vault and it would be good to keep these as distinct elements in the html.
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
I do make use of custom callouts but these get added and changed over time and coding for every possibility would be outside the scope of what I am trying to make here, so I'll just default these and any built-in ones I don't regularly use to just the pin icon 📌
First at the bottom of main.ts I need to define what my html callouts icons will be:
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
Then add the function to do the actual conversion below the constant:
git clone https://github.com/obsidianmd/obsidian-sample-plugin .
Next I wired it in to the exportActiveNote method after the call to convertTags:
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
Now another quick test using my test file:
Looking good:
Step 7 handling Images and embedded notes
I admit I pondered this one for a while. Obsidian allows the embedding of images and notes using ![[{resource name}]], sure I could have solved this by adding placeholder links or recursing through notes, and if this were paid work or for a wider audience I would have done just that. However this is a quick weekend project that I only want to spend a few hours on so I figured we'd just mark these as removed or a dumb link with a placeholder filename and deal with them when I upload to the blog.
In practice this works fine, when I paste the HTML into Squarespace I just swap the placeholder for an actual image upload, which I'd have to do anyway since the image files live in my vault not on the web server.
This step needed two regex functions added:
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
Then as is the pattern wire them into the exportActiveNote method, this needs to be done before the call to convertWikilinks as that only looks for [[{some value}]] and would munch images and embedded notes by matching on ![[{some file name}]]
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
Testing time, I added the following to the test file:
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
and sweet as a nut they were converted to placeholders in the html:
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
Step 8 preserving mermaid diagrams
I make heavy use of mermaid diagrams and I'd like these to be preserved. In this step I added code that will preserve the code and mark its div with the class mermaid. In a later step I'll work on both wiring up the relevant scripts in the output html and prepping my blog site to handle these scripts and markup.
The reason for preserving rather than converting is that Mermaid diagrams need the Mermaid JavaScript library to render at runtime in the browser, and might require a static a headless browser to work, which outside the scope of this project. So emitting a <div class="mermaid"> with the raw diagram code inside, and let the Mermaid library on the Squarespace page handle the rendering seemed like a quick solve.
So two new functions, one to extract the mermaid code blocks and another to restore them once the other processing has been completed. This prevents the call to marked from mangling them.
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
Then we add calls to extractMermaid and extractCodeBlocks. extractMermaid runs first, and its returned body is passed into extractCodeBlocks. Then after code blocks are restored we restore the mermaid blocks, but only once marked has run:
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
To test I added a quick mermaid diagram to the test note:
Then confirmed the inclusion of the div with class mermaid:
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
As mentioned this won't render properly just yet; we'll need to sort that in a later step.
Step 9 Fixing up the Code blocks
This step cleans up the HTML class names that marked puts on code blocks so Prism can correctly syntax-highlight them, and extracts any filename annotation (like title:foo.py) to render it as a visible label above the block. Prism is a JavaScript syntax highlighting library I'll be adding to the Squarespace page to automatically find <pre><code class="language-python"> blocks in my HTML. It will add colours to the code keywords, strings, comments etc. Without Prism the code blocks would just be plain monospace text with no colouring.
One gotcha I hit here: marked strips the title:foo.py part from the code fence info string before it emits the HTML class, so by the time we see the HTML output the filename is already gone. The fix is to pre-process the markdown body before passing it to marked, injecting the filename as a <div> that marked will pass through untouched.
Two functions needed:
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
Then in exportActiveNote() I call preprocessCodeFilenames on the body before marked runs. After marked the sentinels are converted to HTML by restoreCodeBlocksHtml, Mermaid blocks are restored, and finally fixCodeFenceClasses cleans up the class names:
mkdir {vault path}\.obsidian\plugins\obs-note-to-snippet\
Testing with the following in the test note:
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from 'node:module';
import { copyFileSync } from 'fs';
const PLUGIN_DIR = "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet";
const copyFilesPlugin = {
name: "copy-files",
setup(build) {
build.onEnd(() => {
copyFileSync("styles.css", `${PLUGIN_DIR}/styles.css`);
copyFileSync("manifest.json", `${PLUGIN_DIR}/manifest.json`);
});
},
};
//...EXISTING CODE ...
outfile: "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet/main.js",
minify: prod,
plugins: [copyFilesPlugin],
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
The output is correct, filename div appears above the block, bare blocks get language-none:
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from 'node:module';
import { copyFileSync } from 'fs';
const PLUGIN_DIR = "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet";
const copyFilesPlugin = {
name: "copy-files",
setup(build) {
build.onEnd(() => {
copyFileSync("styles.css", `${PLUGIN_DIR}/styles.css`);
copyFileSync("manifest.json", `${PLUGIN_DIR}/manifest.json`);
});
},
};
//...EXISTING CODE ...
outfile: "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet/main.js",
minify: prod,
plugins: [copyFilesPlugin],
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
Always be testing
I had considered adding latex support for maths functions but honestly I don't use them often enough to warrant the effort at this point (again unpaid personal weekend project and I can't pay myself can I ;) )
Step 10 Wrapping it all up, CSS, Prism and Mermaid setup
The last step before testing everything; it's a bit of a big one. At the moment, the plugin is outputting raw converted HTML with no styling and no setup instructions. The output is meant to be dropped straight into a Squarespace Code Block so it needs to be a self-contained snippet without <!DOCTYPE>, no <head>, no <body> wrapper, just the content.
This step adds three things:
- Embedded CSS for styling the code blocks, callouts, Mermaid diagrams and filename labels, controlled by the "Embed CSS" setting
- Prism setup comment a user-friendly HTML comment at the top telling me exactly what to add to my Squarespace Code Injection to get syntax highlighting working. I will forget so it is needed.
- Mermaid setup comment this only appears if there is a diagram, it reminds me exactly what to paste into Squarespace Code Injection → Footer (Squarespace blocks inline scripts in Code Blocks, so the scripts can't be included in the exported file itself)
I also wired up the openAfterExport setting so it opens the exported file in Obsidian immediately after saving.
First I added a sanitiseFilename helper to strip any characters that would be illegal in a filename:
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from 'node:module';
import { copyFileSync } from 'fs';
const PLUGIN_DIR = "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet";
const copyFilesPlugin = {
name: "copy-files",
setup(build) {
build.onEnd(() => {
copyFileSync("styles.css", `${PLUGIN_DIR}/styles.css`);
copyFileSync("manifest.json", `${PLUGIN_DIR}/manifest.json`);
});
},
};
//...EXISTING CODE ...
outfile: "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet/main.js",
minify: prod,
plugins: [copyFilesPlugin],
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
Then the buildOutput function that assembles the final snippet:
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from 'node:module';
import { copyFileSync } from 'fs';
const PLUGIN_DIR = "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet";
const copyFilesPlugin = {
name: "copy-files",
setup(build) {
build.onEnd(() => {
copyFileSync("styles.css", `${PLUGIN_DIR}/styles.css`);
copyFileSync("manifest.json", `${PLUGIN_DIR}/manifest.json`);
});
},
};
//...EXISTING CODE ...
outfile: "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet/main.js",
minify: prod,
plugins: [copyFilesPlugin],
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
The string constants (PRISM_COMMENT, MERMAID_COMMENT, EMBED_CSS) are defined at the bottom of main.ts, they're long but static. The Prism and Mermaid comments are essentially setup guides baked right into every exported file so I don't have to remember what to paste into Squarespace each time.
Finally I updated exportActiveNote() to use all of the above:
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from 'node:module';
import { copyFileSync } from 'fs';
const PLUGIN_DIR = "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet";
const copyFilesPlugin = {
name: "copy-files",
setup(build) {
build.onEnd(() => {
copyFileSync("styles.css", `${PLUGIN_DIR}/styles.css`);
copyFileSync("manifest.json", `${PLUGIN_DIR}/manifest.json`);
});
},
};
//...EXISTING CODE ...
outfile: "D:/OneDrive/Obsidian Vault/MyVault/.obsidian/plugins/obs-note-to-snippet/main.js",
minify: prod,
plugins: [copyFilesPlugin],
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
Tested the full pipeline, the exported file now starts with the Prism setup comment, includes the embedded CSS styles, and when the note has a Mermaid diagram the Mermaid setup comment is included automatically (reminding me what to paste into Code Injection → Footer).
The exported file itself is a wall of HTML, which is exactly what I want and not meant to be human-readable, just pasted into Squarespace. The Prism comment at the top is the first thing I see, which reminds me of what is needed before copying. The CSS block is self-contained so the styles live in the content. Opening the file in a browser before pasting is a sanity check and it should look like a styled blog post even without Squarespace around it.
That's it, the plugin is done, at least version one of it.
Step 11 getting a test page working in Squarespace
The last step was to hunt down the code injection in Squarespace:
Then add the relevant header and footer scripts:
One thing I hit when testing on Squarespace, the platform blocks inline <script> tags in Code Blocks for security reasons, so the Mermaid runtime scripts can't be included in the exported HTML. Instead they need to go in **Code Injection → Footer** alongside the Prism scripts. The Mermaid comment at the top of any exported file that contains a diagram tells you exactly what to paste there. I updated buildOutput() to remove the inline Mermaid scripts, the comment is all that's needed in the output. For the full Squarespace one-time setup: - **Header**, the Prism stylesheet <link> tag - **Footer**, the two Prism <script> tags, and if using Mermaid, the two Mermaid scripts below them Once those are in Code Injection they apply to every page on the site. Job done.
Conclusion
Not bad for a bank holiday weekend project. It does exactly what I built it to do, open a note, hit the ribbon icon, and get an HTML file I can paste straight into Squarespace with maybe one or two manual tweaks for images. The whole thing from first commit to working Squarespace page took about a day, which is longer than I had planned for, but this was largely down to the hard to pin down bug that was mangling inline code blocks.
If I were to keep going I'd probably add proper image handling and maybe better tables. But it's done and it's useful, and for an unpaid weekend project that's good enough.
I've stashed the files in GitHub, https://github.com/martynmakes/obs-note-to-snippet if anyone fancies tinkering with it. I also tested it with this very post and will probably go back and fix up the others.