Plugins
Writing a Plugin
A Stedefast plugin wraps one or more remark/rehype packages in a simple object. This guide covers four levels of plugin complexity.
Part 1: Wrapping an existing package
The simplest plugin wraps a package that already exists in the unified ecosystem:
import remarkSmartypants from "remark-smartypants";
import type { StedefastPlugin } from "@stedefast/core";
export function SmartypantsPlugin(): StedefastPlugin {
return {
name: "smartypants",
remarkPlugins: [
[remarkSmartypants, { dashes: "oldschool" }],
],
};
}
// stedefast.config.ts
plugins: [SmartypantsPlugin()],
That's it. Any remark or rehype plugin from the unified ecosystem works the same way.
Part 2: Writing a remark plugin
Remark plugins operate on the Markdown AST before it is converted to HTML. Use unist-util-visit to walk the tree and transform nodes.
Here's a plugin that wraps every external link in a <span class="external-link">:
import { visit } from "unist-util-visit";
import type { Root } from "mdast";
import type { Plugin } from "unified";
import type { StedefastPlugin } from "@stedefast/core";
const remarkExternalLinks: Plugin<[], Root> = () => (tree) => {
visit(tree, "link", (node) => {
const href = node.url;
if (href.startsWith("http://") || href.startsWith("https://")) {
// Add data property — rehype will convert this to an attribute
node.data = node.data ?? {};
node.data.hProperties = {
...((node.data.hProperties as Record<string, unknown>) ?? {}),
target: "_blank",
rel: "noopener noreferrer",
class: "external-link",
};
}
});
};
export function ExternalLinksPlugin(): StedefastPlugin {
return {
name: "external-links",
remarkPlugins: [remarkExternalLinks],
};
}
Part 3: Writing a rehype plugin
Rehype plugins operate on the HTML AST after conversion from Markdown. Use hast-util-visit to walk <element> nodes.
Here's a plugin that adds loading="lazy" to every <img>:
import { visit } from "unist-util-visit";
import type { Root, Element } from "hast";
import type { Plugin } from "unified";
import type { StedefastPlugin } from "@stedefast/core";
const rehypeLazyImages: Plugin<[], Root> = () => (tree) => {
visit(tree, "element", (node: Element) => {
if (node.tagName === "img") {
node.properties = {
...node.properties,
loading: "lazy",
decoding: "async",
};
}
});
};
export function LazyImagesPlugin(): StedefastPlugin {
return {
name: "lazy-images",
rehypePlugins: [rehypeLazyImages],
};
}
Part 4: Injecting head assets
Use headAssets to inject scripts or stylesheets into every page's <head>:
export function PrismPlugin(): StedefastPlugin {
return {
name: "prism",
headAssets: {
scripts: ["https://cdn.jsdelivr.net/npm/prismjs/prism.min.js"],
styles: ["https://cdn.jsdelivr.net/npm/prismjs/themes/prism-dark.min.css"],
},
// rehypePlugins: [...] add class="language-*" if needed
};
}
Assets are deduplicated — if two plugins inject the same URL, it only appears once in the rendered <head>.
Testing a plugin
Test plugins using parseMarkdown() directly from /content:
import { describe, it, expect } from "vitest";
import { parseMarkdown } from "/content";
import { LazyImagesPlugin } from "./lazy-images.js";
describe("LazyImagesPlugin", () => {
it("adds loading=lazy to images", async () => {
const plugin = LazyImagesPlugin();
const { html } = await parseMarkdown(
"",
{ rehypePlugins: plugin.rehypePlugins ?? [] },
);
expect(html).toContain('loading="lazy"');
});
});
Publishing a plugin
Publish plugins to npm with a stedefast-plugin- prefix for discoverability:
# Package name convention
stedefast-plugin-prism
stedefast-plugin-reading-time
stedefast-plugin-copy-code
The package should:
- Export a factory function returning
StedefastPlugin - Set
peerDependencies: { "/core": "*" } - Use
"type": "module"and build withtsup - Include usage docs in
README.md