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
nameand 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,filePathanddirectDependenciesare the base.- The plugin that loads a kind of file picks its
typeand puts on the object whatever it needs, an AST, code or bytes. - Every other plugin checks the
typeand 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.
setup, once.- Load the entry points.
read, thenload, thenpostLoadfor each file. - Resolve and load.
sourcesProvidercollects the references in each file,resolveSourceandtransformSourcesettle them, and whatever they point at is loaded the same way. Repeats until nothing new turns up. preTransform, once.transform, once per plugin per metadata.postTransform, once.write, once.postBuild, once.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,cloneandhandleRequest. - 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. |