Print

Platform servicesStable
@wabbit/tome-printv0.1.6

Tome platform print layer — document assembly, template registry, and Pandoc-driven PDF generation for structured reports.

Install
  1. Add our registry to the .npmrc at the root of your project. Free packages install without a token.

    @wabbit:registry=https://npm.wabbit.com/
  2. Then install:

    npm install @wabbit/tome-print

Overview

@wabbit/tome-print

Document assembly, a print-template registry, and Pandoc-driven PDF generation for structured reports (e.g. @wabbit/tome-deals' proposal/quote documents). domain layer per root ARCHITECTURE.md — zero @wabbit/* dependencies.

Relationship to `@wabbit/tome-deals` — read this before wiring invoices. This package never calls registerLayer and exposes no job-dispatch function, so installing it does not make deals print anything. Deals prints only through the printAdapter you pass to its layer: to print deal documents, call this package's mapDocument/assembleDocument (or the CLI) from inside that adapter.

Install

pnpm add @wabbit/tome-print

No peer dependencies — this is a standalone Node package (document assembly + a CLI wrapper around two external binaries). It ships zero React and zero .tsx files.

External requirements — two system binaries, not one: the ./cli build path (tome-print bin → src/cli/build.ts) needs both:

  1. Pandoc — Markdown → JSON AST (pandoc -t json). findPandoc() tries pandoc on PATH first, then known Windows install locations (%LOCALAPPDATA%\Pandoc\pandoc.exe, C:\Program Files\Pandoc\pandoc.exe); if none resolve, the CLI exits with an install-url error rather than a cryptic spawn failure.
  2. WeasyPrint (Python) — HTML → PDF, invoked as a bare weasyprint shell command with no path-search fallback. If it's missing, build.ts catches the execSync failure and prints pip install weasyprint, then preserves the intermediate HTML at .tome-print-tmp/report.html next to the target PDF so the run isn't a total loss. On success the CLI deletes .tome-print-tmp/ and also writes a <output>.debug.html copy beside the PDF.

Neither binary is vendored by pnpm add — both are separate host installs.

60-second quickstart

import { registerPrintTemplate, assembleDocument, mapDocument, extractMeta } from '@wabbit/tome-print'

registerPrintTemplate('quote-line-item', (block, meta) => `<tr><td>${String(block.data.label)}</td></tr>`)

// pandocAst = JSON.parse(output of `pandoc -t json report.md`)
const { meta, blocks } = mapDocument(pandocAst)         // ./mapper — frontmatter + AST → PrintBlock[]
const html = assembleDocument(blocks, meta, 'report.css') // ./document — 3 args: blocks, meta, CSS href for the <link>
const sameMeta = extractMeta(pandocAst.meta)            // takes the AST's `meta` object, not the whole AST

assembleDocument returns an HTML string; turning it into a PDF is your job (the CLI hands it to WeasyPrint). A block whose slug has no registered template renders as an HTML comment and logs a warning — it does not throw. The CLI also runs an internal chart-injection pass (a data-chart block after each matching table) that is not exported, so library callers who go straight from mapDocument to assembleDocument get no auto-charts.

CLI (Markdown → PDF via Pandoc + WeasyPrint, positional args — no subcommand, no flags):

pnpm exec tome-print ./report.md ./report.pdf   # output arg optional — defaults to <input-basename>.pdf beside the input

The compiled CLI opens with #!/usr/bin/env node, so the tome-print bin runs directly on macOS/Linux as well as Windows. You can also call the file with Node directly: node node_modules/@wabbit/tome-print/dist/cli/build.js ./report.md. The CLI is ESM-only in practice (it reads import.meta.url to find report.css).

Set TOME_PRINT_WORDMARK=/abs/path/to/logo.svg before running the CLI to brand the editorial templates with a real wordmark; unset (or a missing file) falls back to a plain-text brand mark — the CLI warns but does not fail.

Public API

| Export | Subpath | Description | |---|---|---| | DocumentMeta, PrintBlock, PrintTemplate | ./types | Core shapes — PrintTemplate = (block, meta) => string | | registerPrintTemplate(slug, template) | ./registry | Register a template function for a block slug | | getPrintTemplate(slug), hasPrintTemplate(slug), getAllPrintTemplates() | ./registry | Registry lookups | | renderBlock(block, meta) | ./registry | Render one block via its registered template | | assembleDocument(blocks, meta, cssPath) | root (./document) | Walk a PrintBlock[] and assemble the final document HTML; cssPath is the stylesheet href written into the <link> tag (relative or absolute — the CLI resolves it to a temp-dir-local report.css before handing off to WeasyPrint) | | mapDocument(pandocAst) | root (./mapper) | Map a Pandoc AST to { meta, blocks } | | extractMeta(pandocMeta) | root (./mapper) | Pull DocumentMeta out of a document's frontmatter — pass the Pandoc AST's meta object (ast.meta), not the whole AST. Defined in mapper/frontmatter.ts but re-exported from both the root barrel and ./mapper; ./mapper/frontmatter is not a subpath in the exports map | | renderInline, renderInlines | ./richtext | Inline-node renderers | | renderBlockNode, renderBlocks | ./richtext | Block-node renderers | | tome-print (bin) | bin only — ./cli is not an importable subpath | Pandoc-driven Markdown → PDF build CLI (dist/cli/build.js) — parses <input.md> via pandoc -t json, maps the AST, injects charts, assembles HTML, then renders to PDF | | Template registration side-effect | ./templates | import '@wabbit/tome-print/templates' registers every built-in template; assembleDocument also imports this internally |

Built-in templates (registered via ./templates's side-effect barrel)

editorial-opener, prose-section, pull-quote, signal-data-table, editorial-bridge, editorial-colophon, metric-strip, data-chart — one file per template under src/templates/, each calling registerPrintTemplate() at import time. A consumer's own block templates (e.g. a quote-line-item slug from a deal artifact) register the same way, either from an app-owned module imported once at startup or alongside ./templates's barrel.

Server / client posture

Not applicable — this package has no .tsx files and no React dependency at all. Only the CLI touches Node APIs (node:child_process to run Pandoc and WeasyPrint, node:fs for the temp files). Everything you can import — the registry, assembleDocument, the mapper, the richtext renderers and the templates — is plain string work with no Node or browser APIs, so it is safe in a build script, a Payload server hook, or a Node CLI. The registry is a module-level Map, so registrations are per process.

Testing

pnpm --filter @wabbit/tome-print test runs the Vitest suite in tests/ (registry, frontmatter, AST mapping, richtext, templates, chart injection, assembleDocument). None of it needs Pandoc or WeasyPrint; the tests feed hand-built Pandoc ASTs.

Extending

New print templates: registerPrintTemplate(slug, (block, meta) => htmlString) in a module of your own, and import that module once before you call assembleDocument (the registry is a shared Map, so the order of registration does not matter, only that it happens first). Registering a built-in slug again replaces the built-in. Inside this package, a new built-in template also gets a line in src/templates/index.ts, which assembleDocument imports for its side effects. New document sources beyond Pandoc AST would need a new ./mapper sibling implementing the same { meta, blocks } output shape consumed by assembleDocument.

Design history

  • Original design: Pandoc + WeasyPrint CLI orchestration behind a registerPrintTemplate/assembleDocument seam, chosen so document generation is pure Node with no React or browser dependency.

Exports

  • @wabbit/tome-print
  • @wabbit/tome-print/types
  • @wabbit/tome-print/registry
  • @wabbit/tome-print/templates
  • @wabbit/tome-print/richtext
  • @wabbit/tome-print/mapper

Changelog

v0.1.6patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.1.5patch

7eda51f: The `tome-print` CLI now opens with a `#!/usr/bin/env node` shebang, so the bin starts on macOS and Linux instead of being handed to the shell.

  • 7eda51f: The `tome-print` CLI now opens with a `#!/usr/bin/env node` shebang, so the bin starts on macOS and Linux instead of being handed to the shell.
  • d9d0079: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata. `injectCharts` now ships a neutral fictional sample dataset in place of one engagement's figures; its section-heading rules for the projection and launch charts now match "What this means going forward" and "How the launch result was built".
v0.1.4patch

0836ef5: dist now raw-Node loadable: relative specifiers get explicit extensions post-build. `build` gains `&& node ../../scripts/fix-dist-extensions.mjs --strict` as its last step, joining the 13 packages that already ran it. tsup builds `bundle: false` and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM `ERR_MODULE_NOT_FOUND`; CJS worse, `require('./x')` finds the ESM `.js` twin and Node 22+ `require(esm)` then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node, `generate:types`, `generate:importmap`, ops scripts, codegen tools. No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable. Two supporting changes made the wiring possible, both in repo scripts rather than package source. `fix-dist-extensions.mjs` now skips bundler-asset specifiers (`.css`, `.module.css`, `.scss`, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since `--strict` exited 1 on any package with a relative stylesheet import. Dotted MODULE names (`./config.meta`, `./x.variants`, `./y.demo`) are deliberately NOT treated as assets and still get `.js`/`.cjs` appended. `assert-node-loadable.mjs` gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under `node_modules` is a peer SKIP (next@15 has no exports map, so `next/image` fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as `SyntaxError: Unexpected token '.'` raised from inside the stylesheet). Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a `directory import` missing `/index`). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.

  • 0836ef5: dist now raw-Node loadable: relative specifiers get explicit extensions post-build. `build` gains `&& node ../../scripts/fix-dist-extensions.mjs --strict` as its last step, joining the 13 packages that already ran it. tsup builds `bundle: false` and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM `ERR_MODULE_NOT_FOUND`; CJS worse, `require('./x')` finds the ESM `.js` twin and Node 22+ `require(esm)` then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node, `generate:types`, `generate:importmap`, ops scripts, codegen tools. No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable. Two supporting changes made the wiring possible, both in repo scripts rather than package source. `fix-dist-extensions.mjs` now skips bundler-asset specifiers (`.css`, `.module.css`, `.scss`, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since `--strict` exited 1 on any package with a relative stylesheet import. Dotted MODULE names (`./config.meta`, `./x.variants`, `./y.demo`) are deliberately NOT treated as assets and still get `.js`/`.cjs` appended. `assert-node-loadable.mjs` gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under `node_modules` is a peer SKIP (next@15 has no exports map, so `next/image` fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as `SyntaxError: Unexpected token '.'` raised from inside the stylesheet). Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a `directory import` missing `/index`). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
v0.1.3patch

36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.

  • 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
v0.1.2patch

bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

  • bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
v0.1.1patch

233c45e: Add tsup build pipeline: emit ESM+CJS+DTS to `dist/`, rewrite `package.json` exports/main/module/types to point at `dist`, copy CSS assets (report.css + fonts/) via standalone post-build script, expose CLI bin entry. Fixes bundler-incompatible raw `.ts` source distribution.

  • 233c45e: Add tsup build pipeline: emit ESM+CJS+DTS to `dist/`, rewrite `package.json` exports/main/module/types to point at `dist`, copy CSS assets (report.css + fonts/) via standalone post-build script, expose CLI bin entry. Fixes bundler-incompatible raw `.ts` source distribution.