Pagecraft

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:

  • options is the single source of truth. The type of options inside run is inferred from it, so a schema change is a compile error at the point that matters. options.opacity is a number because you said f.number, and options.pages is a string you hand to parseSelection. The exported TintOptions is 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.ts may reach pdf-lib, pdf.js or anything under internal/ 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 in internal/options.ts, which imports the schema vocabulary and nothing else. ops/index.test.ts fails 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.
  • limits is 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.
  • ctx carries 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.
  • id is stable for ever. It is in saved recipes, in scripts, and in URLs people have bookmarked. slug is 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 run function may and may not do.
  • CONTRIBUTING.md in the repository — the sign-off, and how to open the PR.