Adding an operation
Two files, a schema and a run function. Everything else is generated.
An operation is a record, not a code path. Adding one means writing two files and registering them; the CLI’s flags, the web app’s form, the reference page and the JSON schema all fall out of the same declaration (ADR-0007).
Two files, because what an operation is and what it does are wanted at
different moments: the registry is read by six surfaces that are not running
anything, and forty-eight PDF engines is a lot to load in order to draw a form
(ADR-0048).
So the declaration is one module and the implementation is another beside it,
joined by an import() a bundler will split on.
Here is the whole of it, start to finish.
1. Declare it
packages/core/src/ops/tint.ts — what it is, what it asks for, and where the
rest of it lives:
import { defineOperation, loadedFrom } from '../operation.js';
import { f, type Infer } from '../schema/index.js';
const options = {
pages: f.pages({
label: 'Pages',
description: 'Which pages to tint. The rest are left exactly as they are.',
default: 'all',
}),
colour: f.color({ label: 'Colour', default: '#fff4d6' }),
opacity: f.number({ label: 'Strength', default: 0.1, min: 0, max: 1, step: 0.05 }),
};
/** What `tint` was given, so `tint.run.ts` can be typed against it. */
export type TintOptions = Infer<typeof options>;
export const tint = defineOperation({
id: 'tint',
title: 'Tint pages',
summary: 'Lay a colour wash over chosen pages.',
category: 'edit',
slug: 'tint-pdf',
keywords: ['tint pdf', 'colour wash', 'shade pages'],
inputs: { accept: ['application/pdf'], min: 1, max: 1, noun: 'a PDF' },
outputs: { kinds: ['pdf'], cardinality: 'one' },
options,
limits: ['A tint is drawn over the page, so it prints. It is not a viewer setting.'],
run: loadedFrom(() => import('./tint.run.js')),
});
packages/core/src/ops/tint.run.ts — everything that costs anything to load:
import { derive } from '../internal/names.js';
import { openForEditing } from '../internal/pages.js';
import { toPdfArtifact } from '../internal/save.js';
import { onlyDocument, type OperationRun } from '../operation.js';
import { parseSelection } from '../selection.js';
import type { TintOptions } from './tint.js';
export const run: OperationRun<TintOptions> = async (documents, options, ctx) => {
const document = onlyDocument(documents);
const chosen = new Set(parseSelection(options.pages, document.pageCount));
const doc = await openForEditing(document.bytes);
doc.getPages().forEach((page, index) => {
if (!chosen.has(index)) return;
// …draw the wash…
});
ctx.onProgress?.({ value: 1, message: `Tinted ${chosen.size} pages` });
return [await toPdfArtifact(doc, { name: derive(document.name, 'tinted') })];
};
Things worth knowing about that:
optionsis the single source of truth. The type ofoptionsinsiderunis inferred from it, so a schema change is a compile error at the point that matters.options.opacityis anumberbecause you saidf.number, andoptions.pagesis astringyou hand toparseSelection. The exportedTintOptionsis how that type crosses to the other file, which is why the implementation cannot drift from the schema.- The declaration may not import the engine. Nothing in
tint.tsmay reachpdf-lib, pdf.js or anything underinternal/that opens a document, because the whole point of the split is that holding the registry costs nothing. A shared option field — the position of a stamp, the page setup six converters share — goes ininternal/options.ts, which imports the schema vocabulary and nothing else.ops/index.test.tsfails by name if a declaration reaches further than that. - The labels are English on purpose. The schema ships inside an MIT SDK. The four translations live in the web app’s message catalogues and are read by shape, so an option that asks a question seventeen other operations already ask reuses their words rather than needing four new ones.
limitsis not a disclaimer. It is what the operation cannot do, in sentences, and it appears on the Tool page and in--help. Overpromising is the fastest way to lose the only thing this product has.ctxcarries progress, an abort signal and capabilities. Report progress where the work is long enough to notice, and check the signal in the loop. The registry clamps what you report and reports 1 at the end, so you do not have to be careful about the last one.idis stable for ever. It is in saved recipes, in scripts, and in URLs people have bookmarked.slugis the URL of the Tool page and is the phrasing people search for, which is usually not the id.
2. Register it
packages/core/src/ops/index.ts — an import, a line in the registry list, and a
line in the named exports. Alphabetical in all three. The index imports the
declaration only; the implementation arrives the first time somebody runs it.
3. Write the test
packages/core/src/ops/tint.test.ts, through the public API, asserting on the
Artifact:
import { describe, expect, it } from 'vitest';
import { runOperation } from '../index.js';
import { makeDocument } from '../test/fixtures.js';
import { readPdf } from '../test/inspect.js';
describe('tint', () => {
it('leaves the pages it was not asked about alone', async () => {
const document = await makeDocument({ pages: ['A', 'B', 'C'] }, 'report.pdf');
const [tinted] = await runOperation('tint', [document], { pages: '2' });
expect((await readPdf(tinted!)).pageCount).toBe(3);
});
});
Build the fixture in code rather than committing a PDF: a test that says
makeDocument({ pages: ['A', 'B', 'C'] }) states the document it is about.
Assert by re-parsing the Artifact with an independent reader, never by
inspecting the code that produced it — an assertion made with the library that
wrote the bytes can agree with a bug.
4. Write the example
packages/core/src/examples.ts — one entry, in registry order:
{
operation: 'tint',
summary: 'Shade the appendix so it is obvious it is not the report.',
given: [{ name: 'report.pdf', shape: 'pages', pages: 6 }],
options: { pages: '5-6', colour: '#fff4d6' },
produces: { files: 1, kind: 'pdf', pages: 6 },
},
examples.test.ts will now build that document, run the operation and hold the
result to produces. The reference page renders the TypeScript call from it,
and the CLI renders the command line. Nothing here is written twice, which is
why none of it can go stale.
5. Write the words
apps/web/src/i18n/messages/<locale>/tools.ts, in all four languages, one block
per operation:
'tool.tint.title': 'Tint PDF',
'tool.tint.summary': 'Lay a colour wash over chosen pages.',
'tool.tint.noun': 'one PDF file',
'tool.tint.action': 'Tint the pages',
'tool.tint.explains': '…a paragraph worth reading…',
'tool.tint.ask.1': '…a question people actually search for…',
'tool.tint.answer.1': '…',
'tool.tint.ask.2': '…',
'tool.tint.answer.2': '…',
'tool.tint.limit.1': '…the same limit as the schema, said for a reader…',
This is not optional and the build enforces it: an operation with no page is a failed build naming the string it wants, in the language it wants it in (ADR-0026). A Tool is an operation with a page, and a page with nothing written on it is worse than no page — it is a search result that wastes somebody’s click.
If your options ask a question no other operation asks, add
options.tint.<name> entries too. If they ask one seventeen others already ask,
you get those words for free.
6. Check it
pnpm check # typecheck, lint, tests, licences
pnpm --filter @pagecraft/core exec vitest run src/ops/tint.test.ts
pnpm build && node packages/cli/dist/bin.js tint --help
pnpm dev # /tint-pdf now exists, in four languages
pnpm check:budgets # after a build: nothing got heavier
Then read --help and the Tool page. You wrote neither, and both should be
right — if either reads badly, the fix is almost always in the schema, which
means it was reading badly in the other three places too.
What you did not have to do
No route. No form. No flag parser. No JSON schema. No entry in a docs sidebar. No translation of an options table. No reference page. And no thought about loading: the second file is fetched the first time somebody runs your operation and never before it, so nobody using the other forty-eight pays for yours. That is the whole argument for operations being data: the barrier to contributing one is a file and some words, and everything downstream of the registry maintains itself.
Where to go next
- Getting started — the API your operation joins.
- Architecture — what your
runfunction may and may not do. CONTRIBUTING.mdin the repository — the sign-off, and how to open the PR.