paratext
v1.0.9
Published
Hyperlinks, inline images, the window title, the clipboard, notifications and the bell, each with a static fallback for a terminal, pipe or agent that cannot show it. Zero dependencies. Drop-in paths for ansi-escapes, terminal-link and term-img.
Maintainers
Readme
paratext owns everything around terminal output that is not the output: hyperlinks,
inline images, the window title, the clipboard, desktop notifications, the working directory
and the bell — OSC, the escape class (ESC ]) that addresses the terminal program
rather than the character grid. Every OSC capability has a static projection, so a pipe or an
agent gets Docs (https://x.dev), never raw escape bytes. Zero dependencies.
Drop-in for ansi-escapes (the package root, 4 / 4), terminal-link
(paratext/terminal-link, 8 / 8) and term-img (paratext/term-img, 18 / 18):
npx burgee migrate moves the imports, each graded by the incumbent's own suite and level
with it.
Paratext is the literary term for everything around a text that is not the text — the
title, the cover, the margins, the notes. This package owns the terminal equivalent. The
intent and design live at
.sdlc/intents/paratext/.
Install
npm install paratext
pnpm add paratext
yarn add paratext
bun add paratextQuick start
import { emit, processRuntime } from 'paratext';
const runtime = processRuntime();
console.log(emit(runtime, 'link', { text: 'Docs', url: 'https://x.dev' }));
// iTerm2: \e]8;;https://x.dev\aDocs\e]8;;\a
// a pipe: Docs (https://x.dev)The same call gives the terminal's bytes where it understands them and the static projection everywhere else; the next section is why that is the only safe default.
Nothing in this layer is detectable
No terminal answers "do you do OSC 1337". So every capability carries a static
projection, and emit returns it whenever support is absent or unknown: an image becomes
its caption, a notification a printed line, a hyperlink text (url). Emitting the bytes and
hoping is what puts \u001B]1337;File=inline=1;… across the screen of anyone who piped your
output to a file, and it is what every incumbent does.
A capability is data
Terminals invent OSC codes faster than any package ships releases — Kitty's graphics protocol, WezTerm's user-vars, Ghostty's progress bar. So the surface is a registry of plain objects rather than a fixed list of functions, and the seven built-ins register through the same public call a third party makes.
import { register } from 'paratext';
register({
name: 'kitty-image',
osc: 'BEL',
when: { tty: true, term: 'xterm-kitty' },
encode: '\u001B_Ga=T,f=100;{base64}\u001B\\',
fallback: '{caption}', // required — there is no opt-out
});No functions, so a capability travels through JSON, is diffable, and can be validated
without running its author's code. check(document) grades one against
paratext/schema.json, the same file every package in the family
ships. paratext/plugin is the host: a plugin's capabilities arrive under capabilities,
and every other key — another layer's tokens, spinners, handlers — is ignored without
complaint.
What "grades against the schema" means, exactly. check() and register() read the
capability entry of that file and enforce required, type, oneOf, const, minLength,
minimum, items, and additionalProperties: false, which between them is every keyword
the capability shape writes. Each refusal names the path — capabilities.link.when.tty, not
just the capability — and carries the family's error code. What it does not read is
$ref, pattern, minItems, maxLength, enum, allOf, anyOf and not: none of them
appears under a capability, so nothing is silently unchecked today, but a keyword added to
the file tomorrow would be. Until 0.3 this was presence-checking only, and when: 'not an
object' was therefore accepted — a string destructures to four empty clauses, so the support
guess said yes and the sequence went into the pipe. That is now a refusal.
Writing a plugin
A plugin is one module whose default export is a plain object: a name, the family's
contract: 1, and its capabilities under capabilities, each filed under its own name.
This one sets the window title (OSC 2, ended by BEL, \u0007) and prints nothing where it
cannot:
// title-plugin.mjs
export default {
name: 'titler',
contract: 1,
capabilities: {
title: {
name: 'title',
osc: 2, // the OSC code, or 'BEL' for the bell and for protocols that are not OSC
when: { tty: true }, // every clause must hold; {} is always
encode: '\u001B]2;{text}\u0007', // the bytes, as a template
fallback: '', // what prints where it cannot; '' is an answer, leaving it out is not
},
},
};paratext check loads it, validates it against the schema, and prints both projections of each
capability, then ok — or, exit 1, every problem it found at once, each with its code and a
fix: to apply:
$ npx paratext check title-plugin.mjs
titler — 1 capabilities
title osc 2 encode "\u001b]2;{text}\u0007" fallback ""
titler: okMigrating
One import per incumbent:
- import ansiEscapes, { cursorTo, eraseLines, link, image, beep } from 'ansi-escapes';
+ import ansiEscapes, { cursorTo, eraseLines, link, image, beep } from 'paratext';- import terminalLink from 'terminal-link';
+ import terminalLink from 'paratext/terminal-link';- import terminalImage, { UnsupportedTerminalError } from 'term-img';
+ import terminalImage, { UnsupportedTerminalError } from 'paratext/term-img';paratext/term-img takes a file path or image bytes, as term-img does. It is the one entry
that imports node:fs, to read that path; the root and every other entry touch nothing but
strings (Coming from term-img).
The root is ansi-escapes' surface, both halves of it — setCwd included.
The CSI half — the cursor, erasing, scroll regions, the alternate screen, synchronized
output — is byte-exact with [email protected]. It does not degrade, because the
incumbent's does not: a cursor move silently dropped would corrupt the screen of a program
that relied on it.
The CSI half is also published on its own, as paratext/csi — 2,700 bytes, and it
registers none of the built-ins the root does. It is where flagstaff and caique take their
cursor moves from:
import { cursorUp, eraseLines, synchronizedOutput } from 'paratext/csi';The OSC half — link, image, setCwd, beep — gives the incumbent's bytes where the
terminal understands them and the static projection everywhere else. That is the one
deliberate difference.
iTerm and ConEmu are still declared, as undefined, so a drop-in module loads rather
than dying on an ESM named import — and TypeScript types them such that calling one is a
compile error, not a surprise at run time. iTerm.annotation has no equivalent yet.
Or let the codemod make the change: npx burgee migrate --dry-run lists every import it would
rewrite — only drop-ins graded level with their incumbent — and npx burgee migrate makes it
(Migrate).
Compatibility
Each drop-in path is graded by its incumbent's own suite, unedited, through compat-oracle.
Graded by the compat oracle against [email protected]'s own suite: 4 / 4, level with
the control. Three of its four cases assert CSI; until the CSI half landed, that row was
1 / 4.
paratext/terminal-link is graded by terminal-link's suite and paratext/term-img by
term-img's, and both are level with the incumbent. term-img's six path cases went green when
paratext/term-img began reading a path the way term-img does. The current grades are
generated under Benchmarks below and published on the
compatibility page.
Benchmarks
Every number here is produced by npm run bench and published at burgee.interlace.tools/docs/benchmarks.
Graded by the incumbent's own test suite:
| suite | passing |
| :-- | --: |
| ansi-escapes | 4 / 4 |
| term-img | 18 / 18 |
| terminal-link | 8 / 8 |
Weight, installed and tree-inclusive: 119,655 bytes against 2,235,987 for the incumbents it replaces — a ratio of 0.0535.
For agents
- A pipe or an agent never sees raw escape bytes. Every capability has a static projection,
and
emitreturns it whenever support is absent or unknown:Docs (https://x.dev), not\u001B]8;;…. - A capability is data. No functions, so a capability travels through JSON, is diffable,
and
check(document)grades it againstparatext/schema.jsonwithout running its author's code; each refusal names the path and the family's error code. - A plugin can be checked before it ships.
npx paratext check ./kitty.mjsvalidates it and exits 0, 1 with a code and a fix, or 2 on a usage error. - The docs are machine-readable at paratext.interlace.tools/llms.txt and llms-full.txt.
API
emit, register, check and processRuntime from the root, beside the whole ansi-escapes
surface; paratext/plugin for the host. Every export, with its types, is on
paratext.interlace.tools.
Where it sits
Plugins register under the capabilities key, against the one schema the whole family shares.
caique, controlroom, flagstaff build on it, and it builds on nothing in this family.
The family
Ten packages, one repository, one release pipeline. Each installs and works on its own, and each
owns one job. Six of them — bellpull, closeout, linegauge, paratext, roundel, seniority
— depend on nothing; the others depend only on packages in this table, and every dependency points
one way, down the layers.
No package declares a dependency from outside the family; react and react-reconciler are
optional peers of controlroom, which npm does not install.
package-shape-lock
holds that for every manifest, and
independence-install-lock
installs each package alone and finds nothing but the family packages it declares.
Releases are published by one workflow through npm trusted publishing: no npm token is used, and
each release carries SLSA provenance, which npm view <package> dist.attestations shows.
trusted-publishing-lock
keeps both true.
| Package | What it is | Migrates from | | :-- | :-- | :-- | | burgee | The CLI framework: one declaration, every surface | commander, yargs and meow | | roundel | Colour: one output policy, semantic tokens, a theme | chalk and picocolors | | flagstaff | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 | | caique | Prompts that are flags first, and never hang | @inquirer/core | | linegauge | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line | | paratext (this package) | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img | | seniority | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc | | closeout | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime | | bellpull | Subprocesses, and which executable actually ran | cross-spawn and which | | controlroom | Keyboard-driven terminal screens, inline or full-screen | ink |
Every migration guide, and the family-wide compatibility and benchmarks pages, are on burgee.interlace.tools.
Contributing
Issues and pull requests are welcome at ofri-peretz/burgee; read CONTRIBUTING.md first. Report a vulnerability privately, as SECURITY.md describes — never in a public issue.
Licence
MIT © Ofri Peretz — see LICENSE.
