Plugins

A plugin is a TypeScript module that bundles hooks, a config schema, and optional setup logic into a distributable package. Plugins are loaded at startup from your config/site.yaml and can be managed from the admin panel at Plugins (🔌).

Installing a plugin

Add a plugins: list to config/site.yaml. Each entry needs at minimum a src pointing to the plugin module:

# config/site.yaml
plugins:
  - src: "./plugins/my-plugin/mod.ts"
  - src: "jsr:@dune/seo"
    config:
      sitemap: true
      robots: true
  - src: "npm:dune-analytics"
    config:
      provider: plausible
      domain: example.com

Or use the CLI:

dune plugin:install ./plugins/my-plugin/mod.ts
dune plugin:install jsr:@dune/seo

This adds the entry to site.yaml automatically. Run dune dev to activate it.

Plugin source formats

Format Example Description
Local path ./plugins/my-plugin/mod.ts TypeScript file relative to site root
JSR jsr:@scope/plugin-name Package from the JSR registry
npm npm:dune-plugin-name npm package (resolved via Deno)
HTTPS https://example.com/plugin.ts Arbitrary URL import

Plugin module format

A plugin module must export a DunePlugin as its default export, either as a plain object or as a factory function.

Object form

Use this when your plugin does not need configuration:

// plugins/logger/mod.ts
import type { DunePlugin } from "../../src/hooks/types.ts";

export default {
  name: "logger",
  version: "1.0.0",
  description: "Logs every request to the console",
  author: "Your Name",

  hooks: {
    onRequest: ({ data }) => {
      const url = new URL(data.req.url);
      console.log(`[${new Date().toISOString()}] ${data.req.method} ${url.pathname}`);
    },
  },
} satisfies DunePlugin;

Factory form

Use this when your plugin needs to read its config at initialization time. The factory receives the merged plugin config (site.yaml static config merged with any admin-saved overrides):

// plugins/analytics/mod.ts
import type { DunePlugin } from "../../src/hooks/types.ts";

export interface AnalyticsConfig {
  provider?: "plausible" | "umami";
  domain?: string;
  enabled?: boolean;
}

export default function createAnalytics(config: AnalyticsConfig = {}): DunePlugin {
  const { provider = "plausible", domain = "", enabled = true } = config;

  return {
    name: "analytics",
    version: "1.0.0",
    description: "Privacy-focused page view analytics",

    hooks: {
      onAfterRender: ({ data }) => {
        if (!enabled || !domain) return;

        const snippet = provider === "plausible"
          ? `<script defer data-domain="${domain}" src="https://plausible.io/js/script.js"></script>`
          : `<script async src="https://analytics.umami.is/script.js" data-website-id="${domain}"></script>`;

        data.html = data.html.replace("</head>", `${snippet}\n</head>`);
      },
    },
  };
}

Plugin fields

Field Required Description
name Unique identifier — used as the key in config.plugins
version SemVer version string
description Human-readable description shown in the admin panel
author Author name or contact shown in the admin panel
hooks Map of hook event names to handler functions (can be empty {})
configSchema Blueprint-style field definitions for admin-driven config UI
setup One-time initialization function called when the plugin is registered
dependencies Names of other plugins this plugin requires (soft warning at startup)

Static assets

If your plugin needs to serve CSS, JavaScript, images, or other static files, place them in an assets/ subdirectory next to your plugin's mod.ts:

plugins/my-plugin/
  mod.ts
  assets/
    widget.css
    widget.js
    logo.svg

Dune detects the assets/ directory automatically at startup and serves its contents at /__plugins/{name}/. No config required.

<!-- Reference plugin assets from your theme templates -->
<link rel="stylesheet" href="/__plugins/my-plugin/widget.css">
<script src="/__plugins/my-plugin/widget.js" defer></script>

Assets are served directly — no bundling or transformation.

Plugin templates

Plugins can contribute Preact/JSX template components by placing them in a templates/ subdirectory:

plugins/my-plugin/
  mod.ts
  templates/
    landing.tsx
    newsletter-confirm.tsx

Plugin templates are appended to the end of the theme resolution chain, after the active theme and all its parents. This means themes can override plugin templates — if the active theme has its own landing.tsx, it takes precedence over the plugin's version.

Plugin dependencies

Declare soft dependencies with the dependencies field. If a named plugin is not installed, Dune logs a warning at startup but continues loading:

export default {
  name: "dune-comments",
  version: "1.0.0",
  dependencies: ["dune-auth"],   // warns if dune-auth is not installed
  hooks: { ... },
} satisfies DunePlugin;

For hard requirements (your plugin cannot function without them), check in setup() and throw with a clear message:

setup: ({ hooks }) => {
  const installed = hooks.plugins().map((p) => p.name);
  if (!installed.includes("dune-auth")) {
    throw new Error("[dune-comments] Requires the dune-auth plugin — install it first.");
  }
},

Config schema

When configSchema is defined, the admin panel renders a typed form for the plugin's settings. Users can edit config values without touching site.yaml. Changes are saved to data/plugins/{name}.json and loaded at the next startup.

import type { DunePlugin } from "../../src/hooks/types.ts";

export default function createSeo(config = {}): DunePlugin {
  return {
    name: "dune-seo",
    version: "1.0.0",
    description: "SEO automation: sitemap, robots meta, canonical URLs",

    configSchema: {
      sitemap: {
        type: "toggle",
        label: "Generate sitemap.xml",
        default: true,
      },
      robots: {
        type: "toggle",
        label: "Add robots meta tag",
        default: true,
      },
      default_robots: {
        type: "text",
        label: "Default robots value",
        default: "index, follow",
      },
      changefreq: {
        type: "select",
        label: "Sitemap change frequency",
        options: {
          always: "Always",
          hourly: "Hourly",
          daily: "Daily",
          weekly: "Weekly",
          monthly: "Monthly",
          yearly: "Yearly",
          never: "Never",
        },
        default: "weekly",
      },
    },

    hooks: {
      onContentIndexReady: async ({ data, config: cfg, storage }) => {
        const pluginCfg = cfg.plugins["dune-seo"] ?? {};
        if (pluginCfg.sitemap === false) return;
        // … generate sitemap
      },
    },
  };
}

Config schema fields use the same types as Flex Object fields: text, textarea, number, toggle, date, selectcolor.

The setup() function

setup() is called once when the plugin is registered — before any hook events fire. It receives a PluginApi object:

interface PluginApi {
  /** Hook registry — register additional hooks dynamically */
  hooks: HookRegistry;
  /** Merged configuration (read-only) */
  config: DuneConfig;
  /** Storage adapter — read and write plugin-specific data */
  storage: StorageAdapter;
}

Use setup() for one-time initialization that needs access to the storage layer or config, or when you need to register hooks conditionally:

export default function createCache(config: CacheConfig = {}): DunePlugin {
  return {
    name: "edge-cache",
    version: "1.0.0",

    setup: async ({ hooks, config: cfg, storage }) => {
      // Only register cache-clearing hooks if a purge URL is configured
      const purgeUrl = (cfg.plugins["edge-cache"] as CacheConfig)?.purge_url;
      if (!purgeUrl) return;

      hooks.on("onCacheInvalidate", async ({ data }) => {
        await fetch(`${purgeUrl}?key=${data.key}`, { method: "POST" });
      });

      // Verify the purge endpoint is reachable at startup
      try {
        await fetch(purgeUrl, { method: "HEAD" });
        console.log("[edge-cache] Purge endpoint reachable ✓");
      } catch {
        console.warn("[edge-cache] Purge endpoint unreachable — cache clearing disabled");
      }
    },

    hooks: {},
  };
}

Accessing plugin config in hooks

Plugin config is always available in hook handlers via config.plugins["plugin-name"]. Config comes from three merged sources (last wins):

  1. Default values from your plugin code
  2. Static config in site.yaml under the config: key
  3. Admin-saved overrides from data/plugins/{name}.json
hooks: {
  onAfterRender: ({ data, config }) => {
    // config.plugins["my-plugin"] is the fully merged config object
    const myConfig = (config.plugins["my-plugin"] ?? {}) as MyPluginConfig;
    if (!myConfig.enabled) return;
    // …
  },
},

CLI commands

Command Description
dune plugin:list List all installed plugins with their hooks and config fields
dune plugin:install <src> Add a plugin to config/site.yaml
dune plugin:remove <src|name> Remove a plugin from config/site.yaml
dune plugin:create [name] Scaffold a new plugin project in plugins/{name}/
dune plugin:publish [name] Publish a local plugin to JSR
dune plugin:search <query> Search JSR for Dune-compatible plugins
dune plugin:update [name] Update a JSR or npm plugin to the latest version

dune plugin:create

Scaffolds a fully-typed plugin template in plugins/{name}/:

dune plugin:create dune-social

Creates:

plugins/dune-social/
  mod.ts        ← plugin with example hooks + typed config interface
  deno.json     ← JSR package metadata
  README.md     ← installation and configuration docs

The generated mod.ts exports a factory function and is ready to register in site.yaml.

dune plugin:list

$ dune plugin:list

Installed plugins (2):

  dune-seo@1.0.0 by Jane Doe  — SEO automation: sitemap, robots meta, canonical URLs
    hooks: onContentIndexReady, onAfterRender
    config fields: sitemap, robots, default_robots, changefreq

  analytics@0.2.0
    hooks: onAfterRender

dune plugin:publish

Publish a local plugin to JSR. Run this from your plugin directory — it validates that jsr.json (or deno.json) is present, then delegates to deno publish:

cd plugins/my-plugin
dune plugin:publish

# Or by name from the site root:
dune plugin:publish my-plugin

Before publishing, make sure your jsr.json has a name in the @scope/package format, a version, and an exports field pointing to your mod.ts.

dune plugin:search

Search JSR for Dune-compatible plugins:

dune plugin:search analytics

# Results:
#   @dune/analytics-plausible@1.2.0
#     Privacy-focused Plausible analytics integration
#     dune plugin:install jsr:@dune/analytics-plausible
#
#   @janedev/dune-umami@0.8.1
#     Umami analytics for Dune CMS
#     dune plugin:install jsr:@janedev/dune-umami

Results are fetched from the JSR search API filtered to the @dune scope and packages tagged with dune-plugin.

dune plugin:update

Update a JSR or npm plugin to its latest published version and patch site.yaml:

# Update a specific plugin
dune plugin:update dune-seo

# Update all JSR/npm plugins
dune plugin:update

The command fetches the latest version from the registry, updates the src: value in config/site.yaml, and prints what changed. Local path plugins (./plugins/…) are skipped.

Admin panel

Open Plugins (🔌) in the admin sidebar to see all installed plugins. For each plugin that defines a configSchema, a Config form is rendered automatically. Changes are saved immediately and take effect on the next server restart.

Config saved via the admin panel is stored in data/plugins/{name}.json. This file is git-tracked so config changes are part of your deployment.

Scaffolding a local plugin

The fastest way to start a plugin:

# Create the scaffold
dune plugin:create my-shortcodes

# Register it
# (plugin:create prints the site.yaml snippet — copy it, or:)
dune plugin:install ./plugins/my-shortcodes/mod.ts

# Start developing
dune dev

Edit plugins/my-shortcodes/mod.ts, add hooks, and the dev server will reload on save.

Distribution

Plugins are standard Deno/TypeScript modules. Publish them so others can install them with a single src: line.

JSR (recommended) — free, TypeScript-native, scoped packages:

# In your plugin directory
deno publish

Users install with:

plugins:
  - src: "jsr:@yourscope/dune-my-plugin"

npm — for broader ecosystem reach:

plugins:
  - src: "npm:dune-my-plugin"

URL — for quick sharing without a registry:

plugins:
  - src: "https://raw.githubusercontent.com/you/plugin/main/mod.ts"

Best practices

Give your plugin a unique name. The name field is also the config key in config.plugins. Use a scoped name (e.g. @yourscope/plugin-name) or a prefixed name (e.g. dune-seo) to avoid collisions.

Provide sensible defaults. Don't require users to configure every option. Every configSchema field should have a default that makes the plugin useful out of the box.

Fail gracefully. If your plugin can't do its job — missing config, network error, optional dependency not installed — log a warning and return early. Never crash the site.

Document your hooks. Tell users which lifecycle events your plugin listens to. The dune plugin:list command shows this automatically, but your README.md should explain why and the performance implications.

Respect stopPropagation. If a previous hook has already handled something, don't re-process it.

Keep setup() fast. setup() runs during server startup. Avoid slow I/O or network calls that would delay boot time. For expensive initialization, fire-and-forget with a logged warning if it fails.