dither
Plugins

@dither/plugin SDK reference

Every export from @dither/plugin with signatures, behavior, and examples.

@dither/plugin is the author SDK for dither plugins. It is a tiny module with no transitive runtime deps, designed to be imported under Deno via the import map the host injects.

Every function must be called from inside a plugin run — the host sets up the surrounding environment, and the SDK will throw if invoked outside of it.

interface PluginInput

interface PluginInput {
  trigger: "scheduled" | "watch" | "manual";
  env: Record<string, string>;
  files: Record<string, string>;
  targets: string[];
}

The shape returned by readInput().

Fields:

  • trigger — what fired the run. Plugins that behave differently on scheduled vs. manual runs can branch on this.
  • env — resolved env values keyed by the manifest's declared name. All values are strings. Plugins coerce in code (Number(input.env.MAX_RUNS), input.env.DEBUG === "true"). Optional values that the user did not supply and that have no default will simply be missing keys.
  • files — absolute paths to the user's file/folder inputs, keyed by id. Each path is on Deno's --allow-read allowlist. Optional file inputs that the user did not supply will simply be missing keys.
  • targets — populated for watch-triggered runs with the paths that changed. Empty for manual and scheduled runs.

interface EntryOptions

interface EntryOptions {
  collection: string;
  body: string;
  frontmatter?: Record<string, unknown>;
  filename?: string;
}

The argument to writeEntry().

  • collection — target collection. May be a nested path (e.g. "messages/tom"). Promotion matches it against the plugin's dither.collections grants (each is a glob pattern); if no grant glob matches, the file is rejected. Path rules: per-segment [a-zA-Z0-9._-], no .., no leading/trailing /, no .md suffix.
  • body — markdown body. Do not include your own frontmatter fence; the SDK emits one for you.
  • frontmatter — optional fields. Three keys are reserved: id, source, collection. Any value you put under those keys is overwritten by the SDK before writing:
    • id — if you set frontmatter.id to a string, it is used as the entry id (and as the default filename). The id must be a flat name — no / or \\, and not . or .. — or writeEntry throws. Otherwise a UUID is generated.
    • source — always overwritten with the plugin's name.
    • collection — always overwritten with opts.collection.
  • filename — optional output filename inside the run dir. Defaults to <id>.md. Must be a flat name (same rules as id). Two outputs with the same filename in the same run overwrite each other; at promote, a clash with an existing entry written by a different plugin is rejected (a clash with this plugin's own previous output is allowed and overwrites).

readInput()

function readInput(): Promise<PluginInput>;

Reads and JSON-parses the file at DITHER_INPUT_FILE and returns it as a PluginInput.

Behavior:

  • Throws if DITHER_INPUT_FILE is unset.
  • Throws if the file is missing or unreadable (it is always present under the host).
  • No schema validation — the runtime returns whatever the JSON contains.

Example (from the echo-config fixture):

import { readInput, writeEntry } from "@dither/plugin";

const input = await readInput();

await writeEntry({
  collection: "echoed",
  frontmatter: {
    external_id: "echo-1",
    greeting: input.env.GREETING,
    max_runs: input.env.MAX_RUNS,
  },
  body: [
    "# Echo result",
    "",
    "Greeting: " + input.env.GREETING,
    "Max runs: " + input.env.MAX_RUNS,
    "Token: " + input.env.API_TOKEN,
  ].join("\n"),
});

writeEntry(opts)

function writeEntry(opts: EntryOptions): Promise<string>;

Serializes one markdown entry into DITHER_RUN_DIR. Returns the absolute path written.

Behavior:

  • Generates a UUID for id unless opts.frontmatter.id is a string.
  • Determines the filename: opts.filename if given, else <id>.md.
  • Builds frontmatter as { ...opts.frontmatter, id, source, collection } — your frontmatter values are spread first, then the three reserved keys are written last so they always win.
  • Emits frontmatter via a minimal YAML serializer that JSON-encodes every value (strings get quoted, numbers/booleans pass through, arrays and objects come out as inline JSON). Most YAML parsers accept this; if you need a particular YAML shape, embed it in body and skip the relevant frontmatter key.
  • Creates dirname(out) if necessary and writes the file UTF-8.
  • Throws if DITHER_RUN_DIR or DITHER_PLUGIN_NAME is unset.

Resulting file:

---
title: "Hi"
id: "<uuid>"
source: "<plugin name>"
collection: "<your collection>"
---

<your body>

(Note the trailing newline after the body — the SDK appends one.)

Example (from the import-folder fixture):

import { writeEntry } from "@dither/plugin";

await writeEntry({
  collection: "imported",
  frontmatter: { external_id: "fixture-1", title: "Hello from fixture" },
  body: "# Hello\n\nThis entry was emitted via the @dither/plugin SDK.",
});

readFile(inputId)

function readFile(inputId: string): Promise<string>;

Reads a file the user supplied as a files[] input. The SDK looks up the absolute path in input.files[inputId] (which the host already added to Deno's --allow-read allowlist) and returns the UTF-8 contents.

Saves the two-step pattern of importing node:fs/promises and indexing into input.files by hand.

Behavior:

  • Throws if the input id is not present in input.files (i.e. not declared in the manifest's files[], or not provided at install time, or not required and not supplied).
  • Always reads as UTF-8. For binary files, fall back to node:fs/promises and input.files[id] directly.
  • Throws if DITHER_INPUT_FILE is unset.

Example (from the read-file fixture):

import { readFile, writeEntry } from "@dither/plugin";

const body = await readFile("SOURCE");

await writeEntry({
  collection: "read",
  frontmatter: { external_id: "from-file" },
  body,
});

readState<T>(initial)

function readState<T>(initial: T): Promise<T>;

Reads and JSON-parses the file at DITHER_STATE_FILE. Returns initial when the file is missing or empty, so the plugin never has to handle a null branch.

Behavior:

  • Returns initial if the file does not exist (first run).
  • Returns initial if the file exists but is empty after trimming.
  • Otherwise returns JSON.parse(content) as T. No schema validation.
  • Throws if DITHER_STATE_FILE is unset.

T is inferred from initial, so most call sites can drop the explicit generic.

Example:

import { readState, writeState } from "@dither/plugin";

const state = await readState({ cursor: 0, lastSeen: "" });
state.cursor += 1;
await writeState(state);

writeState<T>(state)

function writeState<T>(state: T): Promise<void>;

JSON-stringifies state (pretty-printed, 2-space indent) and writes it to DITHER_STATE_FILE, creating the parent directory if necessary.

Behavior:

  • Overwrites the previous state file in full — there is no merge.
  • No size limit, no schema check. Whatever you write is what you get back from readState next run.
  • Throws if DITHER_STATE_FILE is unset.

Example:

import { readState, writeState } from "@dither/plugin";

interface State {
  lastSeen: string;
}

const prev = await readState<State>({ lastSeen: "" });
await writeState<State>({ lastSeen: new Date().toISOString() });

progress(opts)

interface ProgressOptions {
  message: string; // required
  done?: number;
  total?: number;
}
function progress(opts: ProgressOptions): void;

Emit a status update to the host. Synchronous: writes one NDJSON line to stderr with a _dither: "progress" sentinel. The host strips control lines from the stderr stream before forwarding the rest to its own stderr / log file.

Behavior:

  • Throws synchronously if message is missing or empty. message is what the host displays; the other fields are advisory.
  • done and total are passed through verbatim. Future renderers may use them for percent / progress bars; today the CLI only renders message.
  • Plain console.log and console.error are not affected — they keep behaving as normal logging.

Display:

  • dither plugin run <name> (blocking) overwrites a single status line on a TTY. On a non-TTY it appends each message on its own line.
  • dither plugin run <name> --detach writes every message into the log file at <dither-home>/logs/<plugin>-<ms>.log (where <ms> is the spawn epoch in milliseconds).

Example:

import { progress, writeEntry } from "@dither/plugin";

const ids = await fetchIds();
let i = 0;
for (const id of ids) {
  await writeEntry({ collection: "items", frontmatter: { id }, body: "…" });
  if (++i % 25 === 0)
    progress({ message: `synced ${i} / ${ids.length}`, done: i, total: ids.length });
}
progress({ message: `done — ${ids.length} items`, done: ids.length, total: ids.length });

Putting it together

A complete plugin that uses every export:

import { readInput, readState, writeEntry, writeState } from "@dither/plugin";

interface State {
  lastSeen: string;
}

const input = await readInput();
const state = await readState<State>({ lastSeen: "" });

console.error(`trigger=${input.trigger}`);

const now = new Date().toISOString();

await writeEntry({
  collection: "feed",
  frontmatter: { feed_url: input.env.feed_url, fetched_at: now },
  body: `# Run at ${now}\n\nPrevious run: ${state.lastSeen ?? "never"}`,
});

await writeState<State>({ lastSeen: now });