Authoring a Digital Garden plugin
Create, test, and publish plugins for the Digital Garden Eleventy template (oleeskild/digitalgarden, used by the obsidian-digital-garden plugin and Forestry.md). Use when asked to build a garden plugin, add a feature to a digital garden as a plugin, write or edit a garden-plugin.json manifest, or publish a plugin to the community registry.
Authoring a Digital Garden plugin
A garden plugin is a directory that extends a Digital Garden site: markup
injected into named layout slots, site-wide styles/scripts, and build-time
Eleventy/markdown-it hooks. Plugins are distributed as public GitHub repos
with a garden-plugin.json manifest at the repo root, and installed into
src/plugins/<id>/ of a garden repo. Follow this skill end to end; do not
invent extension points that are not listed here.
Where to work
- Inside a garden or template checkout (a repo with
.eleventy.jsandsrc/site/): create your plugin directly atsrc/plugins/<id>/and it runs immediately. - Standalone plugin repo: develop the plugin as its own directory, and
for testing clone https://github.com/oleeskild/digitalgarden, run
npm install, and symlink or copy your plugin intosrc/plugins/<id>/.
The plugin id must match ^[a-z0-9][a-z0-9-]*$, must equal the directory
name, and must NOT start with dg- (reserved for first-party plugins).
Minimal viable plugin
src/plugins/reading-time/
garden-plugin.json
templates/badge.njk
{
"id": "reading-time",
"name": "Reading Time",
"version": "1.0.0",
"description": "Shows an estimated reading time under the note title.",
"author": "Your Name",
"slots": { "notes.header": "templates/badge.njk" },
"noteSettings": ["dgReadingTime"]
}
{% if settings.dgReadingTime === true %}
<div class="reading-time">{{ ((content | striptags).split(" ").length / 200) | round }} min read</div>
{% endif %}
id, name, version, description, author are required. Everything
else is optional. All declared paths must be relative, use /, and
contain no .. — a violation makes the loader (and installers) reject
the plugin.
Slots
Slot templates are plain Nunjucks fragments (no front matter). They are
rendered with the full Eleventy data cascade — meta, settings,
title, tags, created, updated, content, collections, graph,
filetree, page — plus pluginSettings (this plugin's resolved
settings). Always self-gate on your own setting: every enabled plugin's
slots render unconditionally.
| Slot | Renders |
|---|---|
common.head / notes.head / index.head | in <head> (all pages / notes / home page) |
common.header / notes.header / index.header | in <header> after title and tags |
common.beforeContent / notes.beforeContent / index.beforeContent | in <main> before the content |
common.afterContent / notes.afterContent / index.afterContent | in <main> after the content |
common.footer / notes.footer / index.footer | end of <body> (scripts, overlays, tooltips) |
navbar.actions | navbar, where the search button sits |
filetree.actions | filetree sidebar below the site title |
filetree.beforeTitle / filetree.afterTitle | around the filetree site title |
sidebar.top / sidebar.bottom | right-hand sidebar |
floating.bottomRight | fixed stack in the bottom-right corner, filled from the corner upward; core's page panel toggle sits nearest the corner. Render one element per template (a button or link), it becomes a flex item and stacks above the previous one |
Manifest form: "slots": { "<slot>": "file.njk" } or a list of files.
Use common.footer for anything with a <script> or overlay markup.
Render order within a slot is the site owner's call, not the plugin's:
plugins render by the order number on their entry in
src/plugins/plugins.json (lower first, default 0), then by id. In
floating.bottomRight a lower order sits nearer the corner. Example:
{"plugins": {"theme-switcher": {"order": 10}, "random-note": {"order": 20}}}.
Regions (exclusive replacement)
A region replaces a core UI area instead of adding to it — at most one
enabled plugin provides each region (first by id wins; conflicts warn).
Currently: navigation (default: the core navbar; the shipped
dg-filetree plugin claims it to render the folder-tree sidebar).
"regions": { "navigation": "templates/my-nav.njk" }
A custom navigation plugin gets the full data cascade, including the
core-computed filetree data structure, and may include core components
(e.g. fall back to {% include "components/navbar.njk" %}) and render
sub-slots via components/pluginSlot.njk. Users switch navigations by
disabling dg-filetree and enabling yours. Read
src/plugins/dg-filetree/ in the template as the reference.
A navigation plugin MUST render the surfaces other extensions rely on,
in its own markup: the navbar.actions and filetree.actions plugin
slots (the search button lives there), and the site owner's custom
filetree components —
{% for imp in dynamics.filetree.beforeTitle %}{% include imp %}{% endfor %}
plus the same for dynamics.filetree.afterTitle. dg-filetree does
both; copy its template as the starting point.
Build hooks ("hooks": "index.js")
module.exports = {
// Both optional. context = { settings, manifest, pluginDir }
setupMarkdown(md, context) { md.use(require("markdown-it-footnote")); },
setupEleventy(eleventyConfig, context) {
eleventyConfig.addFilter("myFilter", (v) => v);
// To emit a page (needs collections), register a virtual template:
// eleventyConfig.addTemplate("my-page.njk",
// require("fs").readFileSync(require("path").join(context.pluginDir, "templates/page.njk"), "utf8"),
// { permalink: "/my-page.json", eleventyExcludeFromCollections: true });
},
};
Rules: hooks run at config time in Node; they may only require packages
the template already ships (check the template's package.json) or pure
JS files vendored inside the plugin dir — plugins cannot add npm
dependencies. Hook changes need a dev-server restart. Thrown errors are
caught and the hook is skipped with a [plugins] warning.
Settings and per-note flags
"settings": [
{ "key": "label", "name": "Label", "description": "…",
"type": "text", // text | boolean | number | select
"default": "Hello",
"options": ["a", "b"], // select only
"env": "MY_ENV_VAR" } // optional env fallback
],
"noteSettings": ["dgMyFlag"]
- Setting resolution: stored value in
src/plugins/plugins.json→ env var (envor the key) from the garden's.env→default. Read them aspluginSettings.<key>in templates,context.settings.<key>in hooks. noteSettingskeys (camelCase,dg-prefixed by convention) resolve like core note settings: env var of the same name is the global default, a note's frontmatterdg-my-flag: true/falseoverrides per note. Read assettings.<key>in templates.
Styles, scripts, assets
"styles": ["styles/x.scss"]—.scsscompiled per file (_partialsskipped) to/plugins/<id>/styles/x.cssand linked in<head>; plain.csscopied and linked as-is. Prefer the garden's--dg-*CSS variables for colors/spacing so themes keep working."scripts": ["assets/client.js"]— copied and loaded via<script defer>in<head>."assets": ["assets/"]— copied to/plugins/<id>/…, referenced only by your own code.
Test before publishing
From the template/garden checkout:
npm installonce, thennpm run dev— slot template, style, andplugins.jsonedits hot-reload; restart after hook or manifest changes.- Watch for
[plugins] …warnings in the build log — each one is a manifest or runtime problem in your plugin. - Build once with the plugin disabled
(
src/plugins/plugins.json→{"plugins": {"<id>": {"enabled": false}}}) and confirm the site is unchanged. - If the template has a test suite,
npm testmust stay green.
Publish
- Put the plugin at the root of a public GitHub repo
(
garden-plugin.jsonat top level). Suggested repo name:garden-plugin-<id>. - Add a README (what it does, a screenshot, settings) and a screenshot file — the community directory shows it.
- Tag a release matching the manifest version:
git tag v1.0.0 && git push --tags. Installers fetch the latest release tag, falling back to the default branch only when no release exists. Every future change: bumpversion, tag again. - Users can now install it by pasting the repo URL, or manually by
copying the directory into
src/plugins/<id>/. - To list it in the community directory, open a PR against
oleeskild/digitalgarden-pluginsadding one entry tocommunity-plugins.json:{ "id": "<id>", "name": "…", "author": "…", "description": "…", "repo": "you/garden-plugin-<id>", "screenshot": "screenshot.png" }.
Hard rules
- Never require install-time execution: no postinstall steps, no "run this script to finish setup". Installation is file copying only.
- A plugin must never break the build when misconfigured — the loader warns and skips, but keep your own code defensive too.
- Don't touch files outside your plugin directory or claim another
plugin's id. Don't use the
dg-prefix. - Plugin code runs in the site build and in visitors' browsers — keep it small, readable, and dependency-light so users can audit it.
For deeper reference, read the first-party plugins under src/plugins/
in the template repo — dg-link-preview is the smallest, dg-search
the most complete, and dg-filetree the navigation reference.