Imported from feralcreative/2pdf (
AGENTS.md). Install upstream withnpx skills add feralcreative/2pdf. Copyright stays with the author.
2pdf Agent Operating Manual
Node CLI that converts Markdown/HTML to styled PDFs via Puppeteer. Single package, no monorepo, no server, no database, no network API.
When a change makes anything in this file inaccurate—a command, a path, a convention, a prohibition—update this file in the same change.
Commands
| Task | Command |
|---|---|
| Install | npm install |
| Compile SCSS (required before first run) | npx sass assets/styles/pdf.scss public/assets/styles/pdf.css && npx sass assets/styles/pdf.scss public/assets/styles/pdf.min.css --style=compressed |
| Watch SCSS | npx sass --watch assets/styles/pdf.scss:public/assets/styles/pdf.css |
| Convert a file | node bin/2pdf.js path/to/file.md --no-open |
| Convert a directory | node bin/2pdf.js path/to/dir -r |
| Unit tests | npm test |
| Single test file | npx jest test/token-processor.test.js |
| Single test by name | npx jest -t "should replace simple tokens" |
| Watch tests | npm run test:watch |
| Lint | npm run lint |
| Lint + autofix | npm run lint:fix |
| Build (lint + test) | npm run build |
Install globally as 2pdf |
npm install -g . |
There is no typecheck step (plain JS, no TypeScript). npm start and npm run dev run src/index.js directly, which exports a class and does nothing on its own—use node bin/2pdf.js instead.
Baseline
npm run build passes clean as of 2026-08-17: zero lint errors, 17 of 17 tests green. Any failure you see is yours—do not write one off as pre-existing without first checking git stash.
Coverage is still thin: only ConfigManager and TokenProcessor have unit tests. Every rendering path—ContentProcessor, StyleManager, PdfGenerator—is uncovered, so a green suite is weak evidence. Smoke-test real output.
Definition of done
-
npm run lint—clean, no errors. -
npm test—all green. -
If you touched
assets/styles/pdf.scss, recompile intopublic/assets/styles/(see Commands). Nothing reads the SCSS at runtime. -
Smoke-test an actual conversion end to end. Unit tests cover only
ConfigManagerandTokenProcessor; every rendering path is uncovered.printf '# Smoke\n\nHello[^1] ==mark==\n\n[^1]: note\n' > /tmp/smoke.md node bin/2pdf.js /tmp/smoke.md --no-open -
If you changed content processing or CSS, open the generated HTML instead of guessing:
node bin/2pdf.js /tmp/smoke.md --debug --verboseprints a temp dir;open <tempdir>/styled.html. -
If you added or changed a document setting or comment tag, update
README.mdin the same change. It is the user-facing source of truth for the full tag surface.
Prohibitions
- Never hand-edit
public/assets/styles/pdf.css,pdf.min.css, or the.mapfiles, orassets/styles/pdf.css/pdf.min.css. All are compiled output. Editassets/styles/pdf.scssand recompile. - Never edit
package-lock.jsonby hand. Let npm write it. - Do not add dependencies without asking. This tool is deliberately thin.
- Do not upgrade Puppeteer past 21.x without asking.
src/pdf-generator.jscallspage.waitForTimeout(), removed in Puppeteer 22. - Do not commit. Hand over
git add -A && git commit -m "type(scope): subject"and let the user run it. - Do not commit a
2pdf.config..gitignorecovers*.config; onlyconfig/2pdf.config.sampleis tracked. - Do not touch
_deprecated/(the original shell implementation) or_archive/(scratch test files). Both are gitignored and dead. - Do not use
---horizontal rules in Markdown authored for this tool. H1/H2 already get bottom rules from the CSS. Use<!--| PAGE-BREAK -->to break sections. - Never widen a document-setting regex to swallow the closing
-->. See Gotchas.
Architecture
Two entry points: bin/2pdf.js (Commander CLI) and src/index.js (exports ToPdf for programmatic use). The CLI is a thin shell—it resolves the input to a file or directory, constructs one ToPdf per file, and prints results. All orchestration lives in ToPdf.convert().
ToPdf.convert() call chain, Markdown path:
ConfigManager.loadConfig()—finds and parses2pdf.configinto a flatKEY=VALUEmap.TokenProcessor.processTokens(inputPath, config)—up to 3 passes of{{TOKEN}}substitution, skipping code blocks.ContentProcessor.processContent()—runsextractDocumentSettings()(stashed onthis.documentSettings), then PDF-only blocks, page breaks, live-site shields.ContentProcessor.markdownToHtml()—captures the first# H1asdocumentTitle, runsmarked, thenpostProcessHtml()(header IDs, columns, table widths/size/alignment, image widths, code sizes, footnote heading, anchor links, image paths).ToPdfreadscontentProcessor.getDocumentSettings()and resolves alogointo a base64 data URI.StyleManager.applyStyles()—loads compiled CSS, string-substitutes theme colors and every size/spacing setting into it, wraps the body in a full HTML document.- Styled HTML is written to
os.tmpdir()/2pdf-<ms>/styled.html. - Output path is resolved: CLI
-o><!-- filename: -->> input basename (+-vXX.YYwhensequential-outputis on), then thefile-dateUTC stamp is layered on top. PdfGenerator.generatePdf()—Puppeteer renders the temp HTML to the output path.- Post: bump
<!-- version-number: -->in the source file,openthe PDF (macOS), remove the temp dir unless--debug.
HTML input (.html/.htm) skips steps 2–4 in favour of processTokensInContent() + processHtmlContent(), and StyleManager injects only theme-color overrides when the file already has its own CSS.
Dependency direction is one-way: bin → src/index.js → the five processors. The processors never import each other. src/dependency-manager.js is dead code (Chrome/Pandoc detection from the shell era)—nothing requires it.
See docs/architecture.md for the non-obvious coupling in ContentProcessor's marker/<style> protocol.
Conventions
- CommonJS (
require/module.exports). One class per file insrc/,module.exports = ClassName—exceptsrc/index.js, which exports{ ToPdf }. - Filenames in
src/are kebab-case; classes are PascalCase. - Prettier-style formatting: double quotes, semicolons, 2-space indent, ~120 col. Not enforced by a config file—match the surrounding file.
- User-facing progress goes through
chalk+ emoji onconsole.log, one line per pipeline step.no-consoleis off deliberately. - Errors:
throw new Error(...)inside processors;ToPdf.convert()catches everything and returns{ success: false, error }. Never letconvert()throw—the CLI's batch loop depends on the result object. - Document settings are read from HTML comments by regex in
ContentProcessor.extractDocumentSettings(), stored camelCase onsettings, then threaded throughToPdf.convert()intoStyleManager.applyStyles()'s positional parameter list. - Styling settings are applied by string-appending CSS override blocks with
!importantonto the compiled stylesheet (see theapply*methods insrc/style-manager.js). That is the established pattern; follow it rather than introducing a CSS-in-JS layer. - Tests are Jest,
test/<module>.test.js,describe(ClassName) > describe(methodName) > test(...), tmpdir fixtures created inbeforeEach.
Adding a document setting touches four places, in order: extractDocumentSettings() in src/content-processor.js, the local-const block and the applyStyles() call in ToPdf.convert(), a new applyX() in src/style-manager.js plus its parameter, and README.md.
Gotchas
- A fresh clone cannot generate a PDF.
.gitignorehas*.css, so no compiled stylesheet is tracked andStyleManagerthrows "No CSS file found". Compile the SCSS first. This also means CSS changes never travel in a commit—say so when handing over a commit that changespdf.scss. - There are two compiled-CSS locations and only one is read. Runtime loads
public/assets/styles/pdf.min.css, falling back topublic/assets/styles/pdf.css. The copies inassets/styles/are strays from someone compiling into the source directory; they are ignored at runtime. Always targetpublic/assets/styles/. npx sassdrops vendor prefixes that the committed CSS has..vscode/settings.jsonconfigures Live Sass Compiler with autoprefix> 1%, last 2 versions, so the checked-outpublic/assets/styles/*.csscarries-webkit-/-ms-prefixes. A plainnpx sassrecompile produces prefix-free CSS. Harmless for headless Chrome, but expect a large diff-in-spirit; do not "fix" the missing prefixes by hand.- Most of the stylesheet lives inside
@media print. Editing a rule outside that block has no effect on PDF output. Check which block you are in. - Half the setting regexes reject hyphens in the value.
theme-color,theme-color-primary,theme-color-secondary,body-color,link-color,font-size,header-size,body-size,line-height,paragraph-spacing,header-spacing,list-item-spacing,link-underline,page-numbers,disclosure, andversion-numberuse([^-]+?);filename,file-date,highlight-color, andlogouse(.+?). So<!-- disclosure: pre-release -->silently fails to parse. Use(.+?)for anything new, and do not "normalize" the old ones to(.+?)without checking that the non-greedy match still stops at-->for every existing document. - 2pdf's own config wins over the calling project's
config/2pdf.config. Search order is<install>/2pdf.config,<cwd>/2pdf.config,<install>/config/2pdf.config,<cwd>/config/2pdf.config. When 2pdf is installed globally from this checkout and this repo has aconfig/2pdf.config, that one is used. Pass-cto be certain. - Inter is fetched from Google Fonts at render time.
assets/fonts/does not exist, soprocessFontPaths()logs "Fonts directory not found, using web fonts". Offline renders fall back to the system sans-serif—not a bug in your change. sequential-outputwrites to the user's source file.updateVersionInFile()rewrites<!-- version-number: -->in the input Markdown after a successful render. Never enable it in a test fixture you care about.- The footer layout is title / disclosure / date + page, left / center / right—not what an older primer claimed. It is an inline template string duplicated in both branches of
generatePdf(); change both. - Positional parameters, not an options object.
StyleManager.applyStyles()takes 21 positional arguments. Inserting one in the middle silently shifts every later setting. Append at the end. --single-pagerecomputes the page height fromdocument.body.scrollHeightplus a fixed 200px buffer. Tall or late-laying-out content clips. Adjust the buffer ingeneratePdf(), not the CSS.debug-highlight.jsat the root is broken. It callsprocessContent(content, file)expecting{ documentSettings, htmlContent }; the method takes one argument and returns a string. It also readstest-highlight-closing.md, which lives in_archive/. Do not use it as a reference; rewrite or delete it if you need a scratch harness.- Auto-open is macOS-only.
execSync("open ...")runs only whenprocess.platform === "darwin". Batch/directory mode never auto-opens regardless. -o/--outputis ignored in directory mode, with a warning. PDFs land next to their sources.
Credentials
There are none. No environment variables, no API keys, no auth, no .env, and no .env.example—grep -r process.env src/ bin/ returns nothing. 2pdf.config is user-authored plain text (names, company, colour hexes) and is gitignored via *.config; config/2pdf.config.sample is the tracked template and the canonical key list.
If you ever add a secret-bearing key, document its name and source here—never its value, not even masked.
Gitignored paths that must never be read into a commit, a log, or a chat response: *.config, _*, .claude, **/img/*, public/assets/images/logo-cannonball.*.
Commit and PR conventions
- Conventional Commits:
type(scope): subject, imperative mood. Types in use:feat,fix,docs,chore,refactor,style. - Branch: work on
mainunless the user says otherwise. - Never commit or push without the user asking. Hand over a single chained one-liner:
git add -A && git commit -m "...". - Never add AI attribution—no
Co-Authored-By, no "Generated with" footer. - Never commit:
2pdf.config, generated PDFs other than a deliberateREADME.pdfrefresh, temp dirs, compiled CSS (already ignored).
Deep-dive index
- docs/architecture.md—the marker/
<style>injection protocol, HTML-vs-Markdown paths, settings threading. Read before changingContentProcessororStyleManager. - docs/decisions.md—why string-substituted CSS, why endnotes not footnotes, why Puppeteer is pinned. Read before "improving" something that looks wrong.
- docs/debugging.md—flags, temp-file inspection, symptom-to-cause table. Read when output is wrong but nothing errored.
- docs/deployment.md—global install, publish, uninstall. Read before changing
bin,files, orenginesinpackage.json. - README.md—the complete user-facing reference for every document setting and comment tag. The source of truth; do not duplicate it here.