Writing a plugin
This how-to builds a small plugin, @example/text-lint, that adds a text_lint target type:
it checks text files for lines that end in whitespace, and optionally for lines that are too long.
The plugin is an ordinary npm package, and fabr builds it, tests it and publishes it. Along the way
it covers what most plugins need: the plugin package, a target type and the rule that builds it,
running a tool, reading a target’s properties, and tests. The
plugin reference documents everything shown here in full.
You need fabr installed (see the quick start) and Node.js 22.19 or later. A project that uses the finished plugin looks like this:
plugin @fabr-home:@example/text-lint;
text_lint docs_lint { srcs = docs:**/*.md; max_length = 100;}1. The plugin project
Section titled “1. The plugin project”The plugin is a fabr project of its own:
text-lint/ PROJECT.fabr src/rules/TextLint.ts the rule's implementation lib/LINT.fabr the target type, and its rule test/lint.test.ts added in step 2PROJECT.fabr declares the plugin as a fabr_plugin. A fabr_plugin is a js_package with
.fabr files added, so its code is written as any package’s:
plugin @fabr-build/js;
fabr_plugin @example/text-lint { srcs = src:**/*.ts; exports = src:rules/*.ts; provided_deps = @npm:@fabr-build/core:^0.4.0; libs = lib/LINT.fabr;}src:**/*.ts compiles the TypeScript to the root of the package, and exports makes each module
under rules/ importable, which is how fabr reaches a rule’s implementation. @fabr-build/core is
the interface a plugin is written against. It is a provided_deps dependency, npm’s peer
dependency: the plugin compiles against it, but when it runs it uses the copy of core that fabr
itself loads. libs lists the plugin’s .fabr files. Built, the fabr_plugin is the plugin as
it is published: the compiled code with its .fabr files added, and a package.json that marks
it a plugin and lists them. There is no package.json to write; fabr generates it.
A target type is declared in a .fabr file shipped with the plugin. Create lib/LINT.fabr:
# Text files checked for lines that end in whitespace.targetdef text_lint { # The files to check. srcs = REQUIRED FILES;}A rule says which code builds targets of the type. Declare it in LINT.fabr too:
rule text_lint t = @example/text-lint:rules/TextLint;The declaration names the type, a name for the target (t), and the module that implements the
rule: rules/TextLint of the package @example/text-lint. Create that module,
src/rules/TextLint.ts:
import { defineRule, FileSet, MemoryFile } from "@fabr-build/core";
export default defineRule({ requirements(context) { return { build: { srcs: context.getFileProperty("srcs") } }; }, evaluateMaterialized(_context, { srcs }) { const names = [...FileSet.unionAll(...srcs)].map(([name]) => name).sort(); return new FileSet(new Map([["files.txt", MemoryFile.from(names.join("\n") + "\n")]])); },});The implementation has two halves:
requirementsstates the target’s file dependencies, here the files itssrcsproperty names. It returns them without content: fabr resolves them first, and can show them withfabr depswithout building anything.evaluateMaterializedbuilds the target. It is given each requirement with its content, heresrcsas a list ofFileSets, one for each source written. This first version only lists the files, as afiles.txtit returns directly.
A project using the plugin reads LINT.fabr straight from the plugin’s package, and fabr loads the
rule’s module only when it first builds a text_lint target.
Build the package and look at what it contains:
$ fabr build @example/text-lint✓ Compiling @example/text-lint (0.8s)Built @example/text-lint (1.2s)$ fabr ls @example/text-lintlib/LINT.fabrpackage.jsonrules/TextLint.d.tsrules/TextLint.jsrules/TextLint.js.map2. Testing the plugin
Section titled “2. Testing the plugin”A plugin is tested the way it is used: by running fabr, with the plugin installed, on a project
that uses it. A test can do that by starting fabr as a separate process in a temporary directory.
Add a test target to PROJECT.fabr:
js_test e2e { tests = test:**/*.test.ts; deps = @example/text-lint @npm:@fabr-build/cli:^0.4.0 @npm:@types/node:22.15.3;}The test’s dependencies are the plugin, fabr’s command-line package (which brings core with it) and Node.js’s types. They are installed together for the test, as they would be in a project that installed fabr and the plugin, so the fabr the test runs finds the plugin.
Create test/lint.test.ts:
import { spawnSync } from "node:child_process";import * as assert from "node:assert/strict";import * as fs from "node:fs";import * as os from "node:os";import * as path from "node:path";
const FABR = require.resolve("@fabr-build/cli");const CACHE = fs.mkdtempSync(path.join(os.tmpdir(), "text-lint-cache-"));after(() => fs.rmSync(CACHE, { recursive: true, force: true }));
/** Run fabr with `args` in a new project made of `files`. */function fabr(files: { [name: string]: string }, ...args: string[]) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "text-lint-")); for (const [name, content] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true }); fs.writeFileSync(path.join(dir, name), content); } return spawnSync(process.execPath, [FABR, ...args], { cwd: dir, encoding: "utf8", env: { PATH: "/usr/bin:/bin", FABR_CACHE_DIR: CACHE }, });}
/** A project with a text_lint target over its docs, given `properties` as well. */function project(properties = ""): string { return `plugin @fabr-home:@example/text-lint;
text_lint docs_lint { srcs = docs:**/*.md;${properties}}`;}
describe("text_lint", () => { it("lists the files it checks", () => { const result = fabr({ "PROJECT.fabr": project(), "docs/a.md": "# A\n", "docs/b.md": "# B\n" }, "cat", "docs_lint"); assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, "a.md\nb.md\n"); });});The fabr helper writes a project into a new temporary directory and runs fabr there, with its
own cache directory so the tests leave your cache alone. fabr test runs tests with an empty
environment, which is why the helper gives fabr a PATH. The test reads the target’s content
with fabr cat; describe, it and after are provided by fabr’s test runner.
$ fabr test e2e✓ Compiling tests for e2e (0.9s)✓ Testing e2e (1.1s)e2e: 1 test passedBuilt e2e (2.4s)3. Running a tool
Section titled “3. Running a tool”A rule never runs a program itself: it returns a build action that does, and fabr runs and
caches it. The linter is a small shell script. Add it as lib/linter.sh, and to the plugin’s
files with resources = lib/linter.sh; in the fabr_plugin:
#!/bin/sh# Report every line that ends in whitespace, and fail if there are any.found=$(awk '/[[:space:]]$/ { print FILENAME ":" FNR ": ends in whitespace" }' "$@")if [ -n "$found" ]; then echo "$found" exit 1fiecho "$# files checked" > lint-report.txtA tool is a target too. Declare it in LINT.fabr, with a global that names it:
# The program a text_lint target runs over its files.default TEXT_LINTER = @example/text-lint-tools/linter;
default script @example/text-lint-tools/linter { entry = ./linter.sh;}script is core’s target type for a shell script; ./linter.sh is relative to the .fabr file.
A plugin’s .fabr files declare only defaults, so the target is a default too. Because
TEXT_LINTER is a default, a project can point it at a linter of its own. Then change
the rule’s implementation to run the tool:
import { createExecAction, defineRule, FileSet } from "@fabr-build/core";
export default defineRule({ requirements(context) { return { build: { srcs: context.getFileProperty("srcs") } }; }, evaluateMaterialized(context, { srcs }) { return context.getGlobalRunnable("TEXT_LINTER").then(linter => { const files = FileSet.unionAll(...srcs); const names = [...files].map(([name]) => name).sort(); const staged = FileSet.unionAll(files, FileSet.layout({ ".linter": linter })); return createExecAction(staged, linter.toCommandLine(names, { base: ".linter" }), "lint-report.txt"); }); },});getGlobalRunnable("TEXT_LINTER")reads the tool as something to run. A tool is read in the evaluation, not inrequirements: it is run by the build, not part of what the target is made from.createExecAction(files, argv, outputs)returns an action that putsfilesin an empty working directory, runsargvthere, and collects the files matchingoutputsas the target’s result. A non-zero exit fails the target, and the error shows the program’s output. The linter is placed under.linter/, apart from the files it checks, andtoCommandLinegives the command that runs it from there.
Fabr caches the action by what it is given: the files and the command line. Change a source, the linter, or the arguments, and the action runs again; change nothing, and its last result is used.
The target’s content is now the linter’s report, so replace the test with two that build a clean file and a bad one:
describe("text_lint", () => { it("passes clean files", () => { const result = fabr({ "PROJECT.fabr": project(), "docs/guide.md": "# Guide\n" }, "build", "docs_lint"); assert.equal(result.status, 0, result.stderr); });
it("fails a line that ends in whitespace", () => { const result = fabr({ "PROJECT.fabr": project(), "docs/guide.md": "# Guide \n" }, "build", "docs_lint"); assert.equal(result.status, 1); assert.match(result.stderr, /guide\.md:1: ends in whitespace/); });});$ fabr test e2e✓ Compiling @example/text-lint (0.9s)✓ Compiling tests for e2e (0.9s)✓ Testing e2e (1.5s)e2e: 2 tests passedBuilt e2e (3.7s)4. An option
Section titled “4. An option”Add a property to the target type, to limit line length:
targetdef text_lint { # The files to check. srcs = REQUIRED FILES; # The longest a line may be, in characters. Unset, lines may be any length. max_length = STRING;}max_length is a string, not a file dependency, so it is not a requirement: the evaluation reads
it from context and passes it to the linter, with Computable.forAll waiting for both it and
the tool (add Computable to the import):
evaluateMaterialized: (context, { srcs }) => Computable.forAll([context.getGlobalRunnable("TEXT_LINTER"), context.getString("max_length")], (linter, maxLength) => { const files = FileSet.unionAll(...srcs); const names = [...files].map(([name]) => name).sort(); const options = maxLength === undefined ? [] : [`--max-length=${maxLength}`]; const staged = FileSet.unionAll(files, FileSet.layout({ ".linter": linter })); return createExecAction(staged, linter.toCommandLine([...options, ...names], { base: ".linter" }), "lint-report.txt"); }),and teach the linter the option:
#!/bin/sh# Report every line that ends in whitespace, or is longer than --max-length=N# characters where that is given, and fail if there are any.max=0case "$1" in --max-length=*) max="${1#--max-length=}"; shift ;;esacfound=$(awk -v max="$max" ' /[[:space:]]$/ { print FILENAME ":" FNR ": ends in whitespace" } max > 0 && length > max { print FILENAME ":" FNR ": longer than " max " characters" }' "$@")if [ -n "$found" ]; then echo "$found" exit 1fiecho "$# files checked" > lint-report.txtThe option is part of the command line, so changing max_length runs the action again. Add a test
that sets it:
it("fails a line longer than max_length", () => { const result = fabr( { "PROJECT.fabr": project(" max_length = 10;\n"), "docs/guide.md": "# A longer heading\n" }, "build", "docs_lint" ); assert.equal(result.status, 1); assert.match(result.stderr, /guide\.md:1: longer than 10 characters/); });$ fabr test e2e✓ Compiling @example/text-lint (0.9s)✓ Compiling tests for e2e (0.9s)✓ Testing e2e (1.8s)e2e: 3 tests passedBuilt e2e (4.0s)5. Publishing
Section titled “5. Publishing”A sync target publishes the package to npm, at the version written in its entry:
sync release { @npm:@example/text-lint:0.1.0 = @example/text-lint<BUILD_TYPE=release>;}Build it first to see exactly what will be uploaded, then publish:
$ fabr build release$ fabr ls release@npm/@example/text-lint/0.1.0/@example/text-lint-0.1.0.tgz@npm/@example/text-lint/0.1.0/package.json$ fabr sync releaseThe generated package.json lists @fabr-build/core under peerDependencies as ^0.4.0, and
a release build leaves out the source maps. See Publishing packages
for credentials and the rest of the package’s metadata.
A project then uses the plugin by installing it beside fabr and declaring it, as shown in Using a plugin.
Where next
Section titled “Where next”The plugin reference covers the rest of the contract:
- choosing when a rule applies, for example
only under
fabr test; - requirements beyond files: packages, what a target passes on to whatever depends on it, and version conflicts;
- composing targets from other target types;
- tools in a package of their own, for a
tool written in TypeScript rather than a script shipped in
lib/; - repository types.