Skip to content

Writing a plugin

How it works

The app cannot read, parse, resolve, print or write a file on its own. It keeps the list of loaded files and the plugin order. Every time it needs something done, it asks the plugins, in that order.

  • A plugin is a plain object with a name and the hooks it answers.
  • Everything staticbolt does is a plugin. The built-in ones have no special access.

Metadata

A loaded file is a metadata object. The app only knows the base shape.

  • type, id, filePath and directDependencies are the base.
  • The plugin that loads a kind of file picks its type and puts on the object whatever it needs, an AST, code or bytes.
  • Every other plugin checks the type and either handles the file or leaves it alone.
  • The core plugins define the types the built-in plugins work with, HTML, scripts, styles, markdown and assets.
  • Any plugin can add a type of its own.

Both directions

  • The app asks plugins through hooks.
  • A plugin asks the app through this. The app routes the request back through the plugins.

A plugin never parses HTML itself. It calls this.load(path), and the first plugin that claims the file does it. It calls this.stringify(metadata), and the plugin that owns that type prints it. That's how a plugin can create a page, a script or an image mid-build without knowing how any of them work.

The pipeline

A production build asks the plugins in this order. Development runs setup only, then process per request.

  1. setup, once.
  2. Load the entry points. read, then load, then postLoad for each file.
  3. Resolve and load. sourcesProvider collects the references in each file, resolveSource and transformSource settle them, and whatever they point at is loaded the same way. Repeats until nothing new turns up.
  4. preTransform, once.
  5. transform, once per plugin per metadata.
  6. postTransform, once.
  7. write, once.
  8. postBuild, once.
  9. teardown, when the app closes, in reverse order.

Two rules cover most hooks:

  • First one wins. The first plugin that returns answers, the rest are skipped. That is read, load, resolveSource, transformSource, rebaseSource, stringify, clone and handleRequest.
  • Everyone runs. Every other hook runs for every plugin that implements it.

A first plugin

import type { Plugin } from "@staticbolt/core";
import { isHtmlMetadata } from "@staticbolt/core";
export function bannerPlugin(text: string): Plugin {
return {
name: "banner",
transform(metadata) {
if (!isHtmlMetadata(metadata)) return;
metadata.ast.querySelector("body")?.insertAdjacentHTML("afterbegin", `<p class="banner">${text}</p>`);
},
};
}

Add it to .staticbolt.ts like any other plugin:

export default defineConfig({
plugins: [bannerPlugin("Beta"), plugins.coreHtmlPlugin() /* ... */],
});

Order

Plugins run in config order, hook by hook. The core parsers sit last. A plugin above them can claim a file or a tag first.

enforce moves every hook of a plugin. "pre" puts them ahead of the plugins without one, "post" behind them. The config order still holds inside each group. One hook can set its own instead:

{
name: "analyze",
write: { enforce: "pre", handler() { /* before every plain write hook */ } },
postBuild() { /* where the plugin sits in the config */ },
}

Inside a hook

Inside a hook, this is the App, with three things added for the plugin. pluginIndex, pluginName and a log prefixed with the plugin name. Write hooks as methods, not arrow functions, to keep it.

Member What it is
root, outdir, publicDir Absolute paths from the config.
configPath Absolute path of .staticbolt.ts.
production true for build, false for serve.
browserslist The resolved browser targets.
metadataList Every loaded file, see below.
entryPoints Relative paths to load first. loadSourcesPlugin fills it in setup.
emitExclude Metadata that must not be written. Add to it and writeFilesPlugin skips the file.
pluginData A bag for plugins to share data through.
resolver The project resolver. this.resolver.resolve(source, fromFile) follows the tsconfig aliases.
watcher The chokidar watcher, development only.
log info, warn, error, prefixed with the plugin name.

And the methods a plugin calls back into:

Method Does
read(path) Runs the read hooks and returns the file content.
load(relativePath, { code?, type? }) Runs read (unless code is given), load and postLoad. Returns the metadata. Does not add it to metadataList.
resolveAndLoad(list) Resolves every source of the given metadata and loads what it finds, recursively. Pushes the loaded files onto the list you pass, not onto metadataList. Returns that list.
resolve(metadata) Runs resolveSource and transformSource over the metadata's sources.
requestSources(metadata) Every MetadataSource the sourcesProvider hooks return for it.
transform(metadata) Runs every transform hook on it.
transformAndSync(metadata) Runs on new metadata the transforms that already ran on the rest. See transform below.
rebase(metadata, newAbsolutePath) Moves a file. Rewrites its sources through rebaseSource, then runs onMetadataRebase.
stringify(metadata, options) Turns the AST back into code through the stringify hooks.
clone(metadata) A deep copy, through the clone hooks.
addMetadata(metadata) Adds to metadataList. The only way in: no hook adds on its own.
removeMetadata(metadata) Removes from it.
findMetadata({ id }) The first metadata matching every given field, { filePath } works too.
getCompileList(event, filePath) Development: which files a change invalidates, through resolveCompileList.
process(id) Development: loads, resolves and transforms one page on demand.

Metadata types

One object per loaded file. id is the original path relative to the root and never changes. filePath is where the file currently is, relative to the output, and moves when a plugin rebases it. ast is mutated in place and always shows the current state. directDependencies is filled by resolveAndLoad.

These are the types the built-in plugins load and handle. A plugin can bring its own. Pick a type string, return the object from load, and answer stringify and clone for it.

Type type Carries
HtmlMetadata Html ast (node-html-parser Document), scriptsMetadataList, stylesMetadataList for its inline tags
ScriptMetadata Script ast (Babel), module
StyleMetadata Style ast (PostCSS root)
MarkdownMetadata Markdown ast with root, frontmatter and render()
PackageMetadata Package code, packageName, written by bundlePackagesPlugin
TextAssetMetadata TextAsset code
BinaryAssetMetadata BinaryAsset data, or nothing when the file is copied as it is
WebManifestMetadata WebAppManifest ast (the manifest object)

isHtmlMetadata, isScriptMetadata, isStyleMetadata, isMarkdownMetadata, isPackageMetadata, isTextAssetMetadata, isBinaryAssetMetadata and isWebManifestMetadata narrow the type. METADATA_TYPES holds the strings.

An inline <script> or <style> is its own metadata inside the page's lists, keyed by the UUID the core HTML plugin stores on the element as data-metadata-id (CUSTOM_ATTRIBUTES.MetadataID).

Paths

id and filePath are relative paths, and plugins compare them as strings all the time. That only works when everyone writes them the same way. Always go through the path helper exported from @staticbolt/core, never node:path. It uses / on every platform and gives relative paths a ./ prefix.

import { path } from "@staticbolt/core";
path.join("pages", "index.html"); // "./pages/index.html"
path.relative(this.root, absolutePath); // "./sources/a.css"
path.normalize("pages\\a.html"); // "./pages/a.html"

this.root, this.outdir and this.publicDir are absolute. path.join(this.root, metadata.id) gets you back to the disk.

It has join, relative, normalize, dirname, basename, extname, isAbsolute and resolve like node:path, plus isPathMatch for globs, isSubpath, replaceExtension, rebaseRelativePath, trimDotPrefix, segments and parsePatterns.

Hooks

setup and teardown

setup runs once before anything is loaded. Put entry points into this.entryPoints, start servers, open workers. teardown runs on close in reverse order and releases them. It may run after a setup that never finished.

read

read(absolutePath)

Returns the file content as a string. The base plugin reads from disk. A plugin above it can serve a file from memory.

load

load(content, relativePath, { type })

Turns a file into metadata. Return null to say the file must not be loaded at all, nothing to pass. type is the extension without the dot, or whatever the caller asked for.

load(content, relativePath, { type }) {
if (type !== "csv") return;
return {
type: METADATA_TYPES.TextAsset,
id: relativePath,
filePath: relativePath,
directDependencies: new Set(),
code: content,
} satisfies TextAssetMetadata;
},

postLoad

postLoad(metadata)

Runs for every plugin as soon as a file is loaded, before its sources are resolved. The place to drop what the pipeline should not follow, or to record something about the file.

sourcesProvider

sourcesProvider(metadata)

Returns the references inside a file, each as a MetadataSource. That is a source getter and setter over one string in the AST, plus the node it lives on. resolve and rebase use the setter to rewrite the reference in place.

sourcesProvider(metadata) {
if (!isHtmlMetadata(metadata)) return;
return metadata.ast.querySelectorAll("x-include[src]").map(element => ({
get source() {
return element.getAttribute("src")!;
},
set source(value) {
element.setAttribute("src", value);
},
node: element,
}));
},

resolveSource

resolveSource(source, filePath, parentMetadata)

Resolves one reference. Return { source, dependencyID }. source is what gets written back, dependencyID the relative path to load next, if any. The base plugin resolves through this.resolver. Most plugins never need this one.

transformSource

transformSource(source, filePath, parentMetadata, dependencyID)

Rewrites a resolved reference before it lands in the file. Return the new string.

transform

preTransform(), transform(metadata), postTransform()

transform is where the work happens. It runs once per plugin per metadata, in plugin order. A plugin sees what the plugins above it did. preTransform and postTransform run once around the whole phase.

A file loaded mid-build goes through the same steps the entry points did. Load it, resolve and load what it depends on, add everything to metadataList, then call transformAndSync to bring it up to date with the rest. From a transform hook that runs the transforms of the plugins above you and your own, the ones every other file already went through. From postTransform or later it runs every transform.

async transform(metadata) {
if (!isHtmlMetadata(metadata)) return;
const script = await this.load("sources/scripts/tracker.ts");
if (!script) return;
const loaded = await this.resolveAndLoad(script);
this.addMetadata(loaded);
await this.transformAndSync(loaded);
},

rebaseSource and onMetadataRebase

rebaseSource(source, filePath, newAbsolutePath)
onMetadataRebase(metadata, oldAbsolutePath, newAbsolutePath)

rebase moves a file. It asks rebaseSource for the new value of each reference, then tells every plugin through onMetadataRebase. Implement them only for reference kinds the base plugin does not understand.

write

Runs once after every transform. writeFilesPlugin walks metadataList and writes what is not in emitExclude. A plugin with its own output writes it here, or adds to emitExclude to keep a file out.

postBuild

Runs once the output directory is complete. Sitemaps, search indexes, reports.

stringify

stringify(metadata, { format, minify, defer })

Turns an AST back into code. With defer, a plugin can hand the slow part to a worker pool instead. Return the plain code and name the handler with defer({ module, export, payload }), where module is an import.meta.resolve URL. Ignoring defer and doing the work inline is always correct.

clone

clone(metadata)

A deep copy of a metadata, AST included. Needed when a plugin renders the same file more than once.

Development server hooks

Hook Does
handleRequest(requestPath, reply, request) Serves a request itself. Return true when handled. reply and request are fastify's.
resolveRequestPath(requestPath) Maps a URL to a file, /about/ to about/index.html say. Every plugin may rewrite it.
onFileEvent(event, filePath) A watched file was added, changed or removed.
resolveCompileList(compileSet, event, filePath) Adds to compileSet the files a change invalidates besides the file itself.

CLI

cli(config, configPath, projectDirectory) runs on the CliProgram, not the app, before any command runs. It can add commands or options to existing ones. Options are @staticbolt/args-parser definitions with a zod schema.

cli(config, configPath) {
const command = this.defineSubcommand({
name: "stats",
meta: { description: "Print the number of pages." },
options: {
json: { schema: z.boolean().optional(), coerce: this.coerce.boolean, meta: { description: "As JSON." } },
},
});
command.onExecute(async ({ options }) => {
const app = new App(config, configPath);
await app.run();
/* ... */
});
this.addCommand(command);
},

overrideOptions exposes a plugin's own options as --<name>.<key> on a command and merges what the user passes into them:

cli() {
const cliOptions = this.defineOptions({
text: { schema: z.string().optional(), meta: { placeholder: "<text>", description: "The banner text." } },
});
this.overrideOptions("build", options, cliOptions, { name: "banner", description: "Banner options." });
},

The program also has addOptions, addArguments, addOptionsToCommand, addArgumentsToCommand, onExecute, onBeforeExecute, onCommandExecute and onBeforeCommandExecute.

Editor hooks

The language server calls these as plain functions, without this.

lspHtmlData() returns the tags and attributes the plugin adds, in the VS Code custom data format, for completion and hover.

lspEmbeddedLanguages() returns the scripts the plugin embeds in HTML. The language server then serves their regions through the project's TypeScript. See EmbeddedLanguage.

lspValidate(document, report) checks a document on every edit. document is parsed already and gives you elements, select(...tags), textOf(range) and resolve(source). Report with report.error, warn, info or hint on an element, an attribute or a range. It runs over the source as written, before any plugin. Only report what no plugin order can fix. isDynamic, checkFileExists, checkJsonObject, isEmptyElement, isJavaScript and isTypeScriptScript cover the common checks.

lspValidate(document, report) {
for (const element of document.select("x-include")) {
const src = element.attribute("src");
if (!src?.value) {
report.error(element, "<x-include> needs a src");
} else if (!isDynamic(src.value)) {
checkFileExists(src, document, report);
}
}
},

Options

Take an options object, fill the defaults with withDefaults, and document every option with JSDoc. Say what it is, its default, and anything the signature does not say.

export interface BannerOptions {
/**
* Where the banner goes.
*
* @default "afterbegin"
*/
position?: "afterbegin" | "beforeend";
}
export function bannerPlugin(userOptions: BannerOptions = {}): Plugin {
const options = withDefaults(userOptions, { position: "afterbegin" });
/* ... */
}

Create plugins inside the config file rather than importing shared instances. The config file is the only module evaluated again when the dev server reloads it.

Helpers

Everything below is exported from @staticbolt/core.

Helper Use
withDefaults(options, defaults) Deep-fills missing options.
printFmtError(error, { node, filePath }) Prints an error with the file and the element it points at.
path The path helper, see Paths above.
splitHtmlLink(url), isValidRelativePath(url), isHtmlLink(url) Take an href apart, tell a file from a URL or a fragment.
safeReadFile, safeReadFileSync Read a file and get [value, error] instead of a throw.
valueOrError, ValueOrError The [value, error] pattern for your own functions.
hashContent, escapeHtml, humanReadableBytes, kebabToCamelCase Small utilities the built-in plugins share.
DependencyTracker Source to importer map for reload signals in development.
Resolver The project resolver, when a plugin needs one of its own.