I have spent the better part of the last two weeks building this site. This involved learning Eleventy, figuring out Git Actions on my own Gitea server, and more.
My primary goal was to create a place to write about my projects and host my portfolio. The secondary goal was to enable a seamless publishing flow: I wanted to write directly in Obsidian and have the site update automatically when I push new pages to my repository; no manual HTML, WordPress, or other CMS hassles.
You can find the project page here. It talks a bit more about the project in a broader, much shorter, sense.
This was a fun learning process, though I encountered many roadblocks. Being unfamiliar with this stack made for a steep learning curve. The desire for a functional site quickly and to return to game development was in a constant battle with my desire tweak, and alter, and have at least a small idea of what I am doing. The latter won out, and here I have documented a small part of those hurdles.
I am very new to web dev and this entire stack. Additionally, while I have used Obsidian for awhile I am not an expert in markdown. As such, I definitely did some things poorly, inefficiently, or just wrong.
Learning Eleventy (The Easy Part)
Learning Eleventy was surprisingly straightforward, thanks to Learn Eleventy From Scratch. The tutorial was completed in a day. Since web development isn't my primary focus, the guide was followed closely, often copying and pasting code while reading. Fortunately, the guide is so well-written that returning later to make changes was painless.
Obsidian Markdown ≠ Standard Markdown
Writing in Obsidian is my preferred workflow, and the aim was to author all new blog or project pages there without manual conversion to HTML or copying into something like WordPress. Just write, push, and publish. The hope is that this frictionless process will encourage more consistent project documentation.
The first hurdle is that Obsidian uses a modified markdown syntax. While Eleventy has good built-in markdown support via markdown-it, it doesn't handle Obsidian style wikilinks out of the box. Switching to standard markdown is an option, but where is the fun in that. No, we will bend Eleventy and Markdown-It to our will.
Wikilinks: [[Like This]]
Several plugins exist for this. markdown-it-obsidian was attempted but couldn't be configured correctly; my links simply went nowhere. This is almost certainly user error on my end.
Fortunately, @photogabble/eleventy-plugin-interlinker works well out of the box, though it lacks some features addressed later. To set it up:
Install: npm install @photogabble/eleventy-plugin-interlinker
A minimal .eleventy.js should look something like this:
import interlinker from "@photogabble/eleventy-plugin-interlinker";
export default function (eleventyConfig)
{
// Rest of config setup.
eleventyConfig.addPlugin(interlinker);
// Rest of config setup
}
That's it. The plugin makes wikilinks functional. It has other features worth exploring on its GitHub, but also some notable exceptions...
Embedded Images: ![[LikeThis.png]]
The interlinker plugin is great, but it doesn't support images. For a site documenting technical work, that's a deal-breaker. The goal was to drop in screenshots like this:

markdown-it-obsidian-images was tried next, but like the markdown-it-obsidian plugin, it couldn't be made to work, also likely due to a gap in understanding. The solution was to extend the eleventy-plugin-interlinker functionality directly.
The modification hasn't been published yet, but it's straightforward. If you need the code, see below:
You will need to pull, build the package, and add it to your project if you go this route.
wikilink-parser.js
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default class WikilinkParser {
debug(label, data = {}, fsInfo = null) {
if (this.opts?.debugImages) {
console.log('[WikilinkParser:image]', label, data);
if (fsInfo) {
console.log('[WikilinkParser:fs]', fsInfo);
}
}
}
/**
* This regex finds all WikiLink style links: [[id|optional text]] as well as WikiLink style embeds: ![[id]]
*
* @type {RegExp}
*/
wikiLinkRegExp = /(?<!!)(!?)\[\[([^|\n]+?)(\|([^\n]+?))?]]/g;
/**
* @param { import('@photogabble/eleventy-plugin-interlinker').EleventyPluginInterlinkOptions } opts
* @param { DeadLinks } deadLinks
* @param { Map } linkCache
*/
constructor(opts, deadLinks, linkCache) {
this.opts = opts;
this.deadLinks = deadLinks;
this.linkCache = linkCache;
}
/**
* Parses a single WikiLink into the link object understood by the Interlinker.
*
* @param {string} link
* @param {import('@photogabble/eleventy-plugin-interlinker').PageDirectoryService} pageDirectory
* @param {string|undefined} filePathStem
* @return {import('@photogabble/eleventy-plugin-interlinker').WikilinkMeta}
*/
parseSingle(link, pageDirectory, filePathStem = undefined) {
if (this.linkCache.has(link)) {
return this.linkCache.get(link);
}
// Wikilinks starting with a ! are considered Embeds e.g. `![[ ident ]]`
const isEmbed = link.startsWith('!');
const isImage = this.isImageFile(link);
this.debug('parseSingle()', {
link,
isEmbed,
isImage,
filePathStem
});
// By default, we display the linked page's title (or alias if used for lookup). This can be overloaded by
// defining the link text prefixed by a | character, e.g. `[[ ident | custom link text ]]`
const parts = link.slice((isEmbed ? 3 : 2), -2).split("|").map(part => part.trim());
/** @var {import('@photogabble/eleventy-plugin-interlinker').WikilinkMeta} */
const meta = {
title: parts.length === 2 ? parts[1] : null,
// Strip .md and .markdown extensions from the file ident; this is so it can be used for
// filePathStem match if path lookup.
name: parts[0].replace(/.(md|markdown)\s?$/i, ""),
anchor: null,
link,
isEmbed,
isPath: false,
exists: false,
isImage,
resolvingFnName: isEmbed ? 'default-embed' : 'default',
};
////
// Anchor link identification:
// This works similar to Obsidian.md except this doesn't look ahead to check if the referenced anchor exists.
// An anchor link can be referenced by a # character in the file ident, e.g. `[[ ident#anchor-id ]]`.
//
// This supports escaping by prefixing the # with a /, e.g `[[ Page about C/# ]]`
if (meta.name.includes('#')) {
const nameParts = parts[0].split('#').map(part => part.trim());
// Allow for escaping a # when prefixed with a /
if (nameParts[0].at(-1) !== '/') {
meta.name = nameParts[0];
meta.anchor = nameParts[1];
} else {
meta.name = meta.name.replace('/#', '#');
}
}
////
// Path link identification:
// This supports both relative links from the linking files path and lookup from the project root path.
meta.isPath = (meta.name.startsWith('/') || meta.name.startsWith('../') || meta.name.startsWith('./'));
// This is a relative path lookup, need to mutate name so that its absolute path from project
// root so that we can match it on a pages filePathStem.
if (meta.isPath && meta.name.startsWith('.')) {
if (!filePathStem) throw new Error('Unable to do relative path lookup of wikilink.');
this.debug('relative image path BEFORE', meta.name);
const cwd = filePathStem.split('/');
const relative = meta.name.split('/');
const stepsBack = relative.filter(file => file === '..').length;
const processedName = [
...cwd.slice(0, -(stepsBack + 1)),
...relative.filter(file => file !== '..' && file !== '.')
].join('/');
if (!meta.isImage) {
meta.name = processedName.replace(/\.(md|markdown)$/i, "");
} else {
meta.name = processedName;
}
this.debug('relative image path AFTER', meta.name);
}
////
// Custom Resolving Fn:
// If the author has referenced a custom resolving function via inclusion of the `:` character
// then we use that one. Otherwise, use the default resolving functions.
// As with anchor links, this supports escaping the `:` character by prefixing with `/`
if (meta.name.includes(':')) {
const parts = meta.name.split(':').map(part => part.trim());
if (parts[0].at(-1) !== '/') {
if (!this.opts.resolvingFns || this.opts.resolvingFns.has(parts[0]) === false) {
const { found } = pageDirectory.findByLink(meta);
if (!found) throw new Error(`Unable to find resolving fn [${parts[0]}] for wikilink ${link} on page [${filePathStem}]`);
} else {
meta.resolvingFnName = parts[0];
meta.name = parts[1];
}
} else {
meta.name = meta.name.replace('/:', ':');
}
}
if (meta.isImage) {
meta.exists = this.checkImageExists(meta.name);
const exists = meta.exists;
this.debug(
'parseSingle:image',
{ link, isEmbed, isImage, filePathStem },
{
imagePath: meta.name,
fullPath: path.join(process.cwd(), meta.name),
exists
}
);
meta.href = this.getImageUrl(meta.name, filePathStem);
meta.resolvingFnName = 'image-embed';
} else {
const { page, foundByAlias } = pageDirectory.findByLink(meta);
if (page) {
if (foundByAlias) {
meta.title = meta.name;
} else if (meta.title === null && page.data.title) {
meta.title = page.data.title;
}
meta.href = page.url;
meta.path = page.inputPath;
meta.exists = true;
meta.page = page;
} else if (['default', 'default-embed'].includes(meta.resolvingFnName)) {
// If this wikilink goes to a page that doesn't exist, add to deadLinks list and
// update href for stub post.
this.deadLinks.add(link);
meta.href = this.opts.stubUrl;
if (isEmbed) meta.resolvingFnName = '404-embed';
}
}
// Cache discovered meta to link, this cache can then be used by the Markdown render rule
// to display the link.
this.linkCache.set(link, meta);
return meta;
}
/**
* @param {Array<string>} links
* @param {import('@photogabble/eleventy-plugin-interlinker').PageDirectoryService} pageDirectory
* @param {string|undefined} filePathStem
* @return {Array<import('@photogabble/eleventy-plugin-interlinker').WikilinkMeta>}
*/
parseMultiple(links, pageDirectory, filePathStem) {
return links.map(link => this.parseSingle(link, pageDirectory, filePathStem));
}
/**
* Finds all wikilinks within a document (HTML or otherwise) and returns their
* parsed result.
*
* @param {string} document
* @param {import('@photogabble/eleventy-plugin-interlinker').PageDirectoryService} pageDirectory
* @param {string|undefined} filePathStem
* @return {Array<import('@photogabble/eleventy-plugin-interlinker').WikilinkMeta>}
*/
find(document, pageDirectory, filePathStem) {
return this.parseMultiple(
(document.match(this.wikiLinkRegExp) || []),
pageDirectory,
filePathStem
)
}
/**
*Determines if a wikilink points to an image file
* @param {string} link
* @return {boolean}
*/
isImageFile(link) {
const match = link.match(/\[\[([^|\]]+)(?:\|[^\]]*)?]]/);
if (!match) return false;
const filename = match[1].toLowerCase();
const imageExtensions = [
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp',
'.bmp', '.tiff', '.tif', '.ico', '.avif'
];
return imageExtensions.some(ext => filename.endsWith(ext));
}
/**
* Check if an image file exists
* @param {string} imagePath
* @return {boolean}
*/
checkImageExists(imagePath) {
const fullPath = path.join(
process.cwd(),
imagePath
);
const exists = fs.existsSync(fullPath);
return exists;
}
/**
* Generate URL for an image
* @param {string} imagePath
* @return {string}
*/
getImageUrl(imagePath, filePathStem) {
const folderPath = path.dirname(filePathStem);
const fileName = path.basename(imagePath);
if (this.opts.imagePath) {
const basePath = this.opts.imagePath.replace(/\/$/, '');
return `/${basePath}/${fileName}`;
}
return `/${folderPath}/${fileName}`;
}
}
resolvers.js
import {EleventyRenderPlugin} from "@11ty/eleventy";
import {encodeHTML} from 'entities';
/**
* Default Resolving function for converting Wikilinks into html links.
*
* @param {import('@photogabble/eleventy-plugin-interlinker').WikilinkMeta} link
* @param {*} currentPage
* @param {import('./interlinker')} interlinker
* @return {Promise<string|undefined>}
*/
export const defaultResolvingFn = async (link, currentPage, interlinker) => {
const text = encodeHTML(link.title ?? link.name);
let href = link.href;
if (link.anchor) {
href = `${href}#${link.anchor}`;
}
return href === false ? link.link : `<a href="${href}">${text}</a>`;
}
/**
* Default Resolving function for converting Wikilinks into Embeds.
*
* @param {import('@photogabble/eleventy-plugin-interlinker').WikilinkMeta} link
* @param {*} currentPage
* @param {import('./interlinker')} interlinker
* @return {Promise<string|undefined>}
*/
export const defaultEmbedFn = async (link, currentPage, interlinker) => {
if (!link.exists || !interlinker.templateConfig || !interlinker.extensionMap) return;
const page = link.page;
const template = await page.template.read();
const layout = (page.data.hasOwnProperty(interlinker.opts.layoutKey))
? page.data[interlinker.opts.layoutKey]
: interlinker.opts.defaultLayout;
const language = (page.data.hasOwnProperty(interlinker.opts.layoutTemplateLangKey))
? page.data[interlinker.opts.layoutTemplateLangKey]
: interlinker.opts.defaultLayoutLang === null
? page.page.templateSyntax
: interlinker.opts.defaultLayoutLang;
// TODO: (#36) the layout below is liquid, will break if content contains invalid template tags such as passing njk file src
// This is the async compile function from the RenderPlugin.js bundled with 11ty. I'm using it directly here
// to compile the embedded content.
const compiler = EleventyRenderPlugin.String;
// Compile template.content
const contentFn = await compiler(template.content, language, {
templateConfig: interlinker.templateConfig,
extensionMap: interlinker.extensionMap
});
const content = await contentFn({...page.data});
// If we don't have an embed layout wrapping this content, return the compiled result.
if (layout === null) return content;
// The template string is just to invoke the embed layout, the content value is the
// compiled result of template.content.
const tpl = `{% layout "${layout}" %}`
const tplFn = await compiler(tpl, language, {
templateConfig: interlinker.templateConfig,
extensionMap: interlinker.extensionMap
});
return await tplFn({content, ...page.data});
}
/**
* Resolving function for image Wikilinks
* @param {import('@photogabble/eleventy-plugin-interlinker').WikilinkMeta} link
*/
export const imageEmbedFn = async (link) => {
const altText = encodeHTML(link.title ?? link.name);
const src = link.href;
return `<img src="${src}" alt="${altText}">`;
};
With the above modifications, images should work. It handles both the default short URL ([[image.png]]) and longer paths (![[/src/attachments/image.png]]).
For non-default paths, pass the option to the parser:
export default function (eleventyConfig)
{
eleventyConfig.addPlugin(interlinker, {
imagePath: 'attachments'
});
}
My limited knowledge of this full stack, including the underlying languages, meant this took a while.
Callouts, Quotes, Math, and More
We still need fixes for the following:
- Callouts
- Highlights for general text
- Code highlights for code blocks
- Footnotes
- Task lists
- Math
- Quotes
Okay so that list is long, images and wikilinks had their own sections! Thankfully, there are some markdown-it plugins for these that work almost out of the box (more on that later). Install them:
npm install markdown-it
npm install markdown-it-obsidian-callouts
npm install markdown-it-footnote
npm install markdown-it-mark
npm install markdown-it-task-lists
npm install markdown-it-math
npm install temml
npm install highlight.js
Add the imports to .eleventy.js:
import markdownIt from "markdown-it";
import obsidianCallouts from 'markdown-it-obsidian-callouts';
import hljs from 'highlight.js';
import markdownFootnotes from 'markdown-it-footnote';
import markdownHighlight from 'markdown-it-mark';
import markdownTasks from 'markdown-it-task-lists';
import markdownMath from 'markdown-it-math/temml';
Then configure the markdown library. This took me a bit to figure out (not a web developer) and there is a lot of outdated setup guides but its actually really easy:
.eleventy.js
This is not full code, just an example
export default function (eleventyConfig)
{
let options = {
html: true,
breaks: true,
linkify: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre><code class="hljs">' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
'</code></pre>';
} catch (__) { }
}
return '<pre><code class="hljs">' + md.utils.escapeHtml(str) + '</code></pre>';
}
};
const md = new markdownIt(options);
const macros = {};
md.use(obsidianCallouts);
md.use(markdownFootnotes);
md.use(markdownHighlight);
md.use(markdownTasks);
md.use(markdownMath, {
temmlOptions: { macros },
});
eleventyConfig.setLibrary("md", md);
eleventyConfig.addPlugin(interlinker, {
imagePath: 'attachments'
});
}
Set up a markdownIt instance and tell it what plugins to use. That’s it. However, depending on your CSS, you might still need to add styles for these elements to display correctly.
Custom CSS (and oh god I just copy/pasted so much...)
Technically, everything worked at this point. But visually, many pages looked unchanged. As mentioned at the start, the code from the Learn Eleventy From Scratch guide was copy-pasted, only going into the code when necessary to fix something. This meant there was a basic understanding of the stack, but not why some plugin CSS worked and others did not.
Why doesn't it work?
The tutorial builds a full site, including needed CSS, and it is built to be extendable. The bones of everything are there, and the CSS touches basically all of it. So if you drop in plugins using their own CSS, they may not work if those styles are already defined.
Copying and pasting while following the tutorial didn't help, but there was an understanding of where to go to modify things. By inspecting the built site's HTML to find problem points and implementing the needed CSS, a test page was created to verify all formatting: Obsidian Formatting Test. This was used to ensure each plugin worked and displayed properly.
In the future, it would be better to stop once the bones of the site are constructed, before CSS is added, and implement desired features to ensure a base level of functionality. Then the CSS could be added and adjusted incrementally.
Summary
It works! Mostly how I want. I can write directly in Obsidian, as I am doing now, push to my git repository (directly in Obsidian thanks to the community Git plugin), and the website updates automatically. There are likely premade solutions for this, probably even free ones, but I wanted to learn more about web dev, server admin, and Git, and end up with a solution that would encourage better and more frequent project documentation.
This doesn't really cover the non-Eleventy-plus-Obsidian issues. For instance, spinning up my own Git via Gitea, setting up action runners, and the myriad of issues and subsequent troubleshooting involved aren't discussed here. That may be another blog post.
Remaining issues:
While this work a few minor, edge-case issues persist:
eleventy-plugin-interlinkercannot handle header links (e.g[[#Obsidian Markdown ≠ Standard Markdown]]).eleventy-plugin-interlinkercannot handle video links.- NunJuks requires
{%raw%}...{%endraw%}for code examples that include its syntax. (including that example, in Obsidian I had to put raw and endraw twice). - The design and layout need more work. It's getting there but I do not love it.
- Currently a lot of frontmatter needs to be defined on the pages. I would prefer to automate this. To name a few:
- Dates
- Summary
- Hero images
These are minor concerns for now and can be tackled another day. The core goal, a seamless Obsidian-to-web publishing workflow, is successfully achieved.