← Files okrdevARCHIVED FILE
tools/repo-pdf/build.mjs
37.5 KB · Oct 4, 2026 · 12:30 UTC
#!/usr/bin/env node
/**
* build.mjs — render this repository as a single, readable PDF.
*
* node tools/repo-pdf/build.mjs [--out dist/okrdev-repo.pdf] [--no-running-heads]
*
* Markdown files are rendered as documents (headings, tables, callouts, code),
* not dumped as source. Everything else is rendered as a numbered listing with
* syntax highlighting. `examples/` is excluded by default.
*
* The build runs Chromium twice: the first pass discovers which PDF page each
* file lands on, the second bakes those page numbers into the contents pages.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import MarkdownIt from 'markdown-it';
import anchorPlugin from 'markdown-it-anchor';
import taskListPlugin from 'markdown-it-task-lists';
import hljs from 'highlight.js';
const require = createRequire(import.meta.url);
const HERE = path.dirname(fileURLToPath(import.meta.url));
/* ------------------------------------------------------------------ config -- */
const PAGE = {
widthMm: 210, // A4
marginMm: { top: 16, bottom: 16, left: 22, right: 22 },
};
const CONTENT_MM = PAGE.widthMm - PAGE.marginMm.left - PAGE.marginMm.right;
const CONTENT_PT = mmToPt(CONTENT_MM);
const MONO_ADVANCE = 0.602; // DejaVu Sans Mono advance width, in em
/** Extensions that make a file source rather than prose. */
const CODE_EXT = /\.(m?js|cjs|ts|tsx|jsx|ya?ml|json|sh|bash|zsh|css|scss|html?|py|rb|go|rs|toml|ini)$/i;
/**
* Source, workflows, manifests and dotfile config. Detected by extension, by a
* leading dot on the filename, or by a shebang — which is how the extensionless
* scripts in this repo (tests/hooks/pre-push) get caught.
*/
function isCodeFile(root, p) {
const base = path.basename(p);
if (CODE_EXT.test(base) || base.startsWith('.')) return true;
try {
const fd = fs.openSync(path.join(root, p), 'r');
const buf = Buffer.alloc(2);
fs.readSync(fd, buf, 0, 2, 0);
fs.closeSync(fd);
return buf.toString('latin1') === '#!';
} catch { return false; }
}
/** Paths never included, whatever else the tree looks like. */
function exclusions({ withCode, root }) {
const rules = [
{ match: (p) => p === 'examples' || p.startsWith('examples/'),
why: 'Worked example of a fictional company using okrdev — excluded by request.' },
{ match: (p) => p.startsWith('dist/'),
why: 'Build output — including this document itself.' },
{ match: (p) => p.endsWith('package-lock.json') || p.endsWith('pnpm-lock.yaml'),
why: 'Dependency lockfiles — machine-generated noise.' },
{ match: (p) => /\.(png|jpe?g|gif|svg|ico|pdf|zip|woff2?|ttf|otf)$/i.test(p),
why: 'Binary assets — nothing to typeset.' },
];
if (!withCode) {
rules.push({
match: (p) => isCodeFile(root, p),
why: 'Source and config — scripts, workflows, manifests and dotfiles.',
});
}
return rules;
}
/**
* Reading order. Each part claims files by explicit list, by prefix, or both;
* anything a part claims but does not order explicitly is appended
* alphabetically, so new files still land somewhere sensible.
*/
const PARTS = [
{
id: 'overview',
title: 'Overview',
blurb: 'The pitch, the argument underneath it, and the coach contract this repository runs on itself.',
order: ['README.md', 'MANIFESTO.md', 'CLAUDE.md', 'LICENSE'],
},
{
id: 'docs',
title: 'The method, documented',
blurb: 'Reference documentation: every rule and its mechanics, the rituals as runnable scripts, the roles, the adoption ladder, and the optional stack module.',
prefix: 'docs/',
order: [
'docs/method.md', 'docs/rituals.md', 'docs/parking-lot.md', 'docs/roles.md',
'docs/dri-onboarding.md', 'docs/adoption.md', 'docs/ai-coach.md',
'docs/live-coached-sessions.md', 'docs/evidence.md', 'docs/shipping-explained.md',
'docs/stack.md', 'docs/testing.md',
],
},
{
id: 'skills',
title: 'Coach skills',
blurb: 'The prompt files behind each /okrdev: command — the coach’s actual instructions, shipped as a Claude Code plugin.',
prefix: 'skills/',
order: [
'skills/install/SKILL.md', 'skills/plan/SKILL.md', 'skills/checkin/SKILL.md',
'skills/coach/SKILL.md', 'skills/park/SKILL.md', 'skills/triage/SKILL.md',
'skills/side-quest/SKILL.md', 'skills/retro/SKILL.md',
],
},
{
id: 'instance',
title: 'okrdev running on okrdev',
blurb: 'The live instance in this repository: its mission, config, the active cycle, weekly check-ins, the parking lot and the lessons file.',
prefix: 'okrdev/',
order: [
'okrdev/MISSION.md', 'okrdev/config.md', 'okrdev/okrs/2026-Q3.md',
'okrdev/checkins/2026-Q3/2026-W31.md', 'okrdev/checkins/2026-Q3/2026-W32.md',
'okrdev/PARKING_LOT.md', 'okrdev/LESSONS.md',
],
},
{
id: 'templates',
title: 'Templates',
blurb: 'What /okrdev:install writes into a target repo — the OKR scaffolding, the coach block, and the collaboration rails a team opts into at Level 2.',
prefix: 'templates/',
order: [
'templates/CLAUDE-okrdev.md', 'templates/okrdev/config.md', 'templates/okrdev/MISSION.md',
'templates/okrdev/okrs/cycle.md', 'templates/okrdev/checkins/checkin.md',
'templates/okrdev/PARKING_LOT.md', 'templates/okrdev/LESSONS.md',
'templates/github/pull_request_template.md', 'templates/github/CODEOWNERS',
'templates/github/workflows/okr-gate.yml', 'templates/github/workflows/ci.yml',
'templates/github/workflows/neon-cleanup.yml',
'templates/stack/README.md', 'templates/stack/branch-protection.sh',
],
},
{
id: 'tests',
title: 'Tests and verification',
blurb: 'How a prompt framework proves itself: what an install is allowed to write, enumerated, so the checks have something to hold it to.',
prefix: 'tests/',
order: ['tests/install-footprint.md', 'tests/check.sh', 'tests/gate-grammar.test.js', 'tests/hooks/pre-push'],
},
{
id: 'plumbing',
title: 'Plugin and CI plumbing',
blurb: 'The plugin and marketplace manifests, this repository’s own CI workflow, and the headless installer.',
prefixes: ['.claude-plugin/', '.github/'],
order: ['.claude-plugin/plugin.json', '.claude-plugin/marketplace.json', '.github/workflows/check.yml', 'install.sh'],
},
{
id: 'tooling',
title: 'Build tooling',
blurb: 'The generator that produced this document.',
prefix: 'tools/',
},
{
id: 'other',
title: 'Other files',
blurb: 'Everything else tracked in the repository.',
catchAll: true,
},
];
/** Language hints for files whose extension does not settle it. */
const LANG_BY_NAME = {
'CODEOWNERS': 'plaintext',
'LICENSE': 'plaintext',
'pre-push': 'bash',
};
const LANG_BY_EXT = {
'.sh': 'bash', '.bash': 'bash', '.yml': 'yaml', '.yaml': 'yaml', '.json': 'json',
'.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.ts': 'typescript',
'.css': 'css', '.html': 'xml', '.py': 'python', '.txt': 'plaintext',
};
const FENCE_LANG_ALIAS = {
ts: 'typescript', js: 'javascript', sh: 'bash', shell: 'bash', console: 'bash',
yml: 'yaml', md: 'markdown', text: 'plaintext', txt: 'plaintext', '': 'plaintext',
};
/* ------------------------------------------------------------------- utils -- */
function mmToPt(mm) { return (mm * 72) / 25.4; }
function git(args, cwd) {
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
}
function escapeHtml(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"');
}
function slugifyPath(p) {
return p.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
function slugifyHeading(s) {
return s.trim().toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/^-|-$/g, '') || 'section';
}
function longestLine(text) {
let max = 0;
for (const line of text.split('\n')) if (line.length > max) max = line.length;
return max;
}
/**
* Line width at the given quantile. Sizing a long listing to its single widest
* line shrinks the whole file for the sake of one outlier; sizing it to the 97th
* percentile keeps the body readable and lets the handful of stragglers wrap.
*/
function percentileLine(text, q) {
const widths = text.split('\n').map((l) => l.length).sort((a, b) => a - b);
if (!widths.length) return 0;
return widths[Math.min(widths.length - 1, Math.floor(q * widths.length))];
}
/** Font size that keeps `cols` monospace columns inside `availPt`, clamped. */
function fitMonoSize(cols, availPt, min, max) {
if (!cols) return max;
const fit = availPt / (cols * MONO_ADVANCE);
return Math.round(Math.max(min, Math.min(max, fit)) * 10) / 10;
}
/* --------------------------------------------------------- file discovery -- */
function collectFiles(root, { withCode }) {
const rules = exclusions({ withCode, root });
const tracked = git(['ls-files', '-z'], root).split('\0').filter(Boolean);
const kept = [];
const dropped = new Map(); // reason -> [paths]
for (const p of tracked) {
const rule = rules.find((r) => r.match(p));
if (rule) {
if (!dropped.has(rule.why)) dropped.set(rule.why, []);
dropped.get(rule.why).push(p);
continue;
}
kept.push(p);
}
return { kept, dropped };
}
function assignParts(paths) {
const remaining = new Set(paths);
const parts = [];
for (const spec of PARTS) {
const prefixes = spec.prefixes || (spec.prefix ? [spec.prefix] : []);
const claimed = [];
for (const p of spec.order || []) {
if (remaining.has(p)) { claimed.push(p); remaining.delete(p); }
}
if (!spec.catchAll) {
const rest = [...remaining].filter((p) => prefixes.some((pre) => p.startsWith(pre))).sort();
for (const p of rest) { claimed.push(p); remaining.delete(p); }
}
if (claimed.length) parts.push({ ...spec, files: claimed });
}
const leftovers = [...remaining].sort();
if (leftovers.length) {
const catchAll = PARTS.find((p) => p.catchAll);
const existing = parts.find((p) => p.id === catchAll.id);
if (existing) existing.files.push(...leftovers);
else parts.push({ ...catchAll, files: leftovers });
}
return parts;
}
/* ------------------------------------------------------------ markdown ------ */
let currentSlug = 'doc'; // set before each render; used by the anchor plugin
const md = new MarkdownIt({
html: false, // angle-bracket placeholders like <name> are content here
linkify: false,
typographer: false,
breaks: false,
});
md.use(anchorPlugin, {
slugify: (s) => `${currentSlug}--${slugifyHeading(s)}`,
tabIndex: false,
});
md.use(taskListPlugin, { enabled: false, label: false });
/**
* Pull HTML comments out of the source before markdown sees them, so they can be
* typeset as visible notes instead of vanishing. Fenced code is left untouched:
* several files show `<!-- okrdev:start -->` as example content.
*/
function extractComments(src) {
const lines = src.split('\n');
const out = [];
const blocks = [];
const inlines = [];
let fence = null;
let i = 0;
while (i < lines.length) {
const line = lines[i];
const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
if (fence) {
if (fenceMatch && fenceMatch[2][0] === fence.char && fenceMatch[2].length >= fence.len
&& fenceMatch[3].trim() === '') fence = null;
out.push(line); i++; continue;
}
if (fenceMatch) {
fence = { char: fenceMatch[2][0], len: fenceMatch[2].length };
out.push(line); i++; continue;
}
const indent = (line.match(/^\s*/) || [''])[0];
if (line.trim().startsWith('<!--')) {
let j = i;
const buf = [];
let closed = false;
while (j < lines.length) {
buf.push(lines[j]);
if (lines[j].includes('-->')) { closed = true; break; }
j++;
}
if (closed) {
const raw = buf.join('\n');
const end = raw.lastIndexOf('-->');
const body = raw.slice(raw.indexOf('<!--') + 4, end);
const after = raw.slice(end + 3);
out.push(indent.slice(0, 3) + `@@SCB${blocks.length}@@`);
blocks.push(dedent(body));
if (after.trim()) out.push(after);
i = j + 1;
continue;
}
}
if (line.includes('<!--') && line.includes('-->')) {
out.push(line.replace(/<!--([\s\S]*?)-->/g, (_, body) => {
inlines.push(body.trim());
return `@@SCI${inlines.length - 1}@@`;
}));
i++; continue;
}
out.push(line); i++;
}
return { text: out.join('\n'), blocks, inlines, unbalancedFence: Boolean(fence) };
}
function dedent(text) {
const lines = text.replace(/^\n/, '').replace(/\s+$/, '').split('\n');
const indents = lines.slice(1).filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length);
const cut = indents.length ? Math.min(...indents) : 0;
return [lines[0].trim(), ...lines.slice(1).map((l) => l.slice(cut))].join('\n').trim();
}
function commentBlockHtml(text) {
if (!text.includes('\n') && text.length <= 60) {
return `<p><span class="sc-inline">${escapeHtml(text)}</span></p>`;
}
return `<aside class="srccomment"><div class="sc-label">note in source</div>`
+ `<pre>${escapeHtml(text)}</pre></aside>`;
}
/* -------------------------------------------------------------- code render -- */
function highlightTo(code, lang) {
const resolved = FENCE_LANG_ALIAS[lang] ?? lang;
if (resolved && resolved !== 'plaintext' && hljs.getLanguage(resolved)) {
try {
return { html: hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value,
lang: resolved };
} catch { /* fall through to plain */ }
}
return { html: escapeHtml(code), lang: resolved || 'plaintext' };
}
/** Split highlighted HTML on newlines, reopening any spans that straddle a break. */
function splitHighlightedLines(html) {
const lines = [];
const stack = [];
let cur = '';
const pushText = (text) => {
const parts = text.split('\n');
for (let i = 0; i < parts.length; i++) {
if (i > 0) {
cur += '</span>'.repeat(stack.length);
lines.push(cur);
cur = stack.join('');
}
cur += parts[i];
}
};
const tagRe = /(<\/?[a-zA-Z][^>]*>)/g;
let last = 0;
let m;
while ((m = tagRe.exec(html)) !== null) {
pushText(html.slice(last, m.index));
if (m[1].startsWith('</')) { stack.pop(); cur += m[1]; }
else { stack.push(m[1]); cur += m[1]; }
last = tagRe.lastIndex;
}
pushText(html.slice(last));
lines.push(cur);
return lines;
}
/** Wrap each highlighted line in its own element so wrapped lines hang. */
function codeLinesHtml(highlighted) {
return splitHighlightedLines(highlighted)
.map((line) => `<div class="cl">${line || ' '}</div>`)
.join('');
}
function codeBlockHtml(code, lang) {
const body = code.replace(/\n+$/, '');
const { html, lang: resolved } = highlightTo(body, lang);
// Fenced blocks carry ASCII diagrams and aligned command lines, so they are
// sized to their longest line: a wrap here destroys the picture.
const size = fitMonoSize(longestLine(body), CONTENT_PT - mmToPt(7), 6.6, 8.6);
const lineCount = body.split('\n').length;
const tag = resolved && resolved !== 'plaintext'
? `<span class="code-lang">${escapeHtml(resolved)}</span>` : '';
return `<div class="codeblock${lineCount <= 20 ? ' short' : ''}" style="font-size:${size}pt">`
+ `${tag}${codeLinesHtml(html)}</div>`;
}
md.renderer.rules.fence = (tokens, idx) => {
const token = tokens[idx];
const info = (token.info || '').trim().split(/\s+/)[0].toLowerCase();
return codeBlockHtml(token.content, info);
};
md.renderer.rules.code_block = (tokens, idx) => codeBlockHtml(tokens[idx].content, '');
md.renderer.rules.table_open = () => '<div class="tablewrap"><table>';
md.renderer.rules.table_close = () => '</table></div>';
/* ------------------------------------------------------------ link rewriting -- */
let linkContext = { doc: null, index: null };
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx];
const href = token.attrGet('href') || '';
env.deadLinks = env.deadLinks || [];
if (/^(https?:|mailto:|tel:)/i.test(href)) return self.renderToken(tokens, idx, options);
if (href.startsWith('#')) {
token.attrSet('href', `#${currentSlug}--${slugifyHeading(href.slice(1))}`);
return self.renderToken(tokens, idx, options);
}
const [rawPath, fragment] = href.split('#');
const resolved = path.posix.normalize(
path.posix.join(path.posix.dirname(linkContext.doc.path), rawPath || '.'),
).replace(/^\.\//, '');
// A link to a directory (`skills/`, `docs/`) lands on its first file.
const target = linkContext.index.get(resolved)
?? linkContext.index.get(`${resolved}/README.md`)
?? firstUnder(linkContext.index, resolved);
if (target) {
token.attrSet('href', fragment
? `#${target.slug}--${slugifyHeading(fragment)}`
: `#${target.slug}`);
return self.renderToken(tokens, idx, options);
}
// Points somewhere outside this export (examples/, or a path that moved).
env.deadLinks.push(idx);
return '<span class="extref">';
};
md.renderer.rules.link_close = function (tokens, idx, options, env, self) {
const openIdx = (env.deadLinks || []).length
? env.deadLinks[env.deadLinks.length - 1] : -1;
if (openIdx >= 0 && !hasCloserBefore(tokens, openIdx, idx)) {
env.deadLinks.pop();
return '</span>';
}
return self.renderToken(tokens, idx, options);
};
function firstUnder(index, dir) {
const prefix = `${dir.replace(/\/$/, '')}/`;
for (const [p, doc] of index) if (p.startsWith(prefix)) return doc;
return null;
}
function hasCloserBefore(tokens, openIdx, closeIdx) {
let depth = 0;
for (let i = openIdx + 1; i < closeIdx; i++) {
if (tokens[i].type === 'link_open') depth++;
if (tokens[i].type === 'link_close') { if (depth === 0) return true; depth--; }
}
return false;
}
/* -------------------------------------------------------------- doc loading -- */
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
function loadMarkdown(doc, index) {
const raw = fs.readFileSync(doc.abs, 'utf8');
const fmMatch = raw.match(FRONT_MATTER);
const frontMatter = fmMatch ? fmMatch[1] : null;
const source = fmMatch ? raw.slice(fmMatch[0].length) : raw;
const { text, blocks, inlines, unbalancedFence } = extractComments(source);
if (unbalancedFence) console.warn(` ! ${doc.path}: unbalanced code fence`);
currentSlug = doc.slug;
linkContext = { doc, index };
const env = {};
const tokens = md.parse(text, env);
// A file whose single h1 opens the document has that h1 promoted to the title.
const h1s = tokens.filter((t) => t.type === 'heading_open' && t.tag === 'h1');
let shift = 2;
if (h1s.length === 1 && tokens[0] && tokens[0].type === 'heading_open' && tokens[0].tag === 'h1') {
doc.title = tokens[1].content.replace(/`/g, '');
tokens.splice(0, 3);
shift = 1;
}
// Push every remaining heading below the part (h1) and file (h2) levels, so
// Chromium's generated PDF outline nests correctly.
for (const token of tokens) {
if (token.type === 'heading_open' || token.type === 'heading_close') {
const level = Number(token.tag.slice(1));
token.tag = `h${Math.min(6, level + shift)}`;
}
}
let html = md.renderer.render(tokens, md.options, env);
html = html
.replace(/<p>@@SCB(\d+)@@<\/p>/g, (_, n) => commentBlockHtml(blocks[Number(n)]))
.replace(/@@SCB(\d+)@@/g, (_, n) => commentBlockHtml(blocks[Number(n)]))
.replace(/@@SCI(\d+)@@/g, (_, n) => `<span class="sc-inline">${escapeHtml(inlines[Number(n)])}</span>`)
.replace(/<input([^>]*)class="task-list-item-checkbox"([^>]*)>/g,
(m) => `<span class="cb${/checked/.test(m) ? ' on' : ''}"></span>`);
if (frontMatter) {
const size = fitMonoSize(percentileLine(frontMatter, 0.9), CONTENT_PT - mmToPt(7), 7, 8);
html = `<div class="frontmatter" style="font-size:${size}pt">`
+ `<div class="fm-label">front matter</div>`
+ codeLinesHtml(highlightTo(frontMatter, 'yaml').html) + '</div>'
+ html;
}
doc.html = `<div class="md">${html}</div>`;
doc.summary = firstSentence(source);
}
function loadListing(doc) {
const raw = fs.readFileSync(doc.abs, 'utf8').replace(/\n$/, '');
const base = path.basename(doc.path);
const lang = LANG_BY_NAME[base] ?? LANG_BY_EXT[path.extname(doc.path)] ?? 'plaintext';
const { html } = highlightTo(raw, lang);
const lines = splitHighlightedLines(html);
const size = fitMonoSize(percentileLine(raw, 0.97), CONTENT_PT - mmToPt(16), 7, 8.4);
const rows = lines
.map((line, i) => `<div class="row"><span class="n">${i + 1}</span><span class="c">${line || ' '}</span></div>`)
.join('');
doc.language = lang;
doc.html = `<div class="listing" style="font-size:${size}pt">${rows}</div>`;
doc.summary = firstCommentSummary(raw);
}
function firstSentence(markdown) {
const cleaned = markdown
.replace(/```[\s\S]*?```/g, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/^#{1,6}\s.*$/gm, '')
.replace(/^\s*[-*|>]\s?.*$/gm, '');
for (const block of cleaned.split(/\n\s*\n/)) {
const t = block.trim().replace(/\s+/g, ' ');
if (t.length < 25) continue;
const plain = t.replace(/\*\*(.+?)\*\*/g, '$1').replace(/[*_`]/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
const sentence = plain.split(/(?<=[.!?])\s/)[0];
return sentence.length > 150 ? `${sentence.slice(0, 147)}…` : sentence;
}
return '';
}
function firstCommentSummary(source) {
for (const line of source.split('\n').slice(0, 12)) {
const m = line.match(/^\s*(?:#|\/\/|\/\*\*?)\s*(.+?)\s*$/);
if (!m) continue;
const t = m[1].replace(/^[-!]+\s*/, '').trim();
if (t.length > 20 && !/^!/.test(line.trim())) {
return t.length > 150 ? `${t.slice(0, 147)}…` : t;
}
}
return '';
}
/* ------------------------------------------------------------ page assembly -- */
function fontFaceCss() {
const faces = [
['Inter', 400, 'normal', '@fontsource/inter/files/inter-latin-400-normal.woff2'],
['Inter', 500, 'normal', '@fontsource/inter/files/inter-latin-500-normal.woff2'],
['Inter', 600, 'normal', '@fontsource/inter/files/inter-latin-600-normal.woff2'],
['Inter', 700, 'normal', '@fontsource/inter/files/inter-latin-700-normal.woff2'],
['Source Serif 4', 400, 'normal', '@fontsource/source-serif-4/files/source-serif-4-latin-400-normal.woff2'],
['Source Serif 4', 400, 'italic', '@fontsource/source-serif-4/files/source-serif-4-latin-400-italic.woff2'],
['Source Serif 4', 600, 'normal', '@fontsource/source-serif-4/files/source-serif-4-latin-600-normal.woff2'],
['Source Serif 4', 600, 'italic', '@fontsource/source-serif-4/files/source-serif-4-latin-600-italic.woff2'],
['Source Serif 4', 700, 'normal', '@fontsource/source-serif-4/files/source-serif-4-latin-700-normal.woff2'],
];
const out = [];
for (const [family, weight, style, spec] of faces) {
let file;
try { file = require.resolve(spec); } catch { continue; }
const b64 = fs.readFileSync(file).toString('base64');
out.push(`@font-face{font-family:"${family}";font-style:${style};font-weight:${weight};`
+ `font-display:block;src:url(data:font/woff2;base64,${b64}) format("woff2");}`);
}
return out.join('\n');
}
function coverHtml(meta) {
return `<section class="cover">
<div class="cover-top">
<div class="cover-mark">${escapeHtml(meta.remote)}</div>
<div class="cover-rule"></div>
<div class="cover-title">okrdev</div>
<p class="cover-sub">${escapeHtml(meta.tagline)}</p>
<p class="cover-scope">${meta.fileCount} files across ${meta.partCount} parts,
${meta.lineCount.toLocaleString('en-US')} lines, typeset for reading.
What is left out — and why — is listed overleaf.</p>
</div>
<dl class="cover-meta">
<div><dt>Repository</dt><dd>${escapeHtml(meta.remote)}</dd></div>
<div><dt>Branch</dt><dd>${escapeHtml(meta.branch)}</dd></div>
<div><dt>Commit</dt><dd>${escapeHtml(meta.commit)} · ${escapeHtml(meta.commitDate)}</dd></div>
<div><dt>Generated</dt><dd>${escapeHtml(meta.generated)}</dd></div>
</dl>
</section>`;
}
function aboutHtml(meta, dropped) {
const excluded = [...dropped.entries()].map(([why, paths]) => {
const shown = paths.length > 6
? `${paths.slice(0, 4).map((p) => `<code>${escapeHtml(p)}</code>`).join(', ')} and ${paths.length - 4} more`
: paths.map((p) => `<code>${escapeHtml(p)}</code>`).join(', ');
return `<li>${escapeHtml(why)}<br><span class="extref">${shown}</span></li>`;
}).join('');
return `<section class="front">
<h1>About this document</h1>
<p class="lede">Tracked files in reading order, rendered rather than dumped. Markdown becomes
typeset prose; anything else becomes a numbered listing. What is not here comes first,
so nothing goes missing quietly.</p>
<h2>What is not here</h2>
<ul>${excluded}</ul>
<h2>How to read it</h2>
<dl class="legend">
<dt>File openers</dt>
<dd>Every file starts on a new page under its repository path. The running head at the top of
each page repeats that path, so you always know which file you are inside.</dd>
<dt>Front matter</dt>
<dd>YAML front matter is shown verbatim in a bordered panel — including its inline comments,
which a parsed table would have thrown away.</dd>
<dt>Notes in source</dt>
<dd>HTML comments are invisible on GitHub but often carry the real instructions. They are
lifted out and set in grey as <span class="sc-inline">notes</span>. Comments shown inside
fenced code blocks are left alone — there they are example content, not asides.</dd>
<dt>Links</dt>
<dd>Cross-references between files are live: tapping <code>docs/method.md</code> in the text
jumps to that file. References to files outside this export are set in
<span class="extref">plain monospace</span> instead of being linked into a dead end.</dd>
<dt>Code</dt>
<dd>Code blocks are syntax highlighted and auto-sized so ASCII diagrams and wide command
lines stay on one line wherever they fit. Listings of non-markdown files carry line
numbers matching the file on disk.</dd>
<dt>Navigation</dt>
<dd>The contents pages carry page numbers, and the PDF outline mirrors the heading structure
of every file — parts, then files, then each file's own headings.</dd>
</dl>
<h2>Regenerating</h2>
<p>This document is built from the repository itself:</p>
${codeBlockHtml('npm --prefix tools/repo-pdf install\nnode tools/repo-pdf/build.mjs', 'bash')}
<p>Add <code>--with-code</code> to take in the source and config files listed above. The
generator lives in <code>tools/repo-pdf/</code>${meta.toolingPart
? ` and is itself included in Part ${meta.toolingPart} of this document`: ''}.</p>
</section>`;
}
function tocHtml(parts) {
const rows = parts.map((part, i) => {
const files = part.files.map((doc) => `<div class="toc-row">
<a href="#${doc.slug}"><span class="p">${escapeHtml(doc.path)}</span></a>
<span class="d">${escapeHtml(doc.title === doc.path ? '' : doc.title)}</span>
<span class="fill"></span>
<span class="pg">@@PG:${doc.slug}@@</span>
</div>`).join('');
return `<div class="toc-part">
<span class="n">Part ${i + 1}</span>
<span class="t">${escapeHtml(part.title)}</span>
<span class="fill"></span>
<span class="pg">@@PG:part-${part.id}@@</span>
</div>${files}`;
}).join('');
return `<section class="toc front">
<h1>Contents</h1>
${rows}
</section>`;
}
function partHtml(part, number) {
const files = part.files
.map((doc) => `<div>${escapeHtml(doc.path)}</div>`).join('');
// The locator token sits outside every heading: Chromium builds the PDF
// outline from heading text, and a marker inside would show up in it.
return `<section class="part" id="part-${part.id}">
<div class="part-eyebrow">Part ${number}<span class="pgmark">${part.marker}</span></div>
<h1>${escapeHtml(part.title)}</h1>
<p class="part-blurb">${escapeHtml(part.blurb)}</p>
<div class="part-files">${files}</div>
</section>`;
}
function fileHtml(doc, part, runningHeads) {
// With running heads on, the part name is already at the top of every page.
const kicker = runningHeads ? (doc.kind === 'markdown' ? 'markdown' : doc.language) : part.title;
const badge = `${doc.lines} lines`;
const banner = `<header class="file-banner">
<div class="file-kicker"><span>${escapeHtml(kicker)}</span><span class="badge">${badge}</span>
<span class="pgmark">${doc.marker}</span></div>
<h2 class="file-path">${escapeHtml(doc.path)}</h2>
${doc.title && doc.title !== doc.path ? `<p class="file-title">${escapeHtml(doc.title)}</p>` : ''}
<div class="file-rule"></div>
</header>`;
const head = `<div class="runhead">
<span class="rh-part">${escapeHtml(part.title)}</span>
<span class="rh-path">${escapeHtml(doc.path)}</span>
</div>`;
const body = banner + doc.html;
if (!runningHeads) {
return `<section class="file" id="${doc.slug}">${body}</section>`;
}
return `<section class="file" id="${doc.slug}"><table class="filewrap">`
+ `<thead><tr><th>${head}</th></tr></thead>`
+ `<tbody><tr><td>${body}</td></tr></tbody></table></section>`;
}
function buildHtml({ parts, meta, dropped, runningHeads }) {
const css = fs.readFileSync(path.join(HERE, 'print.css'), 'utf8');
const body = parts.map((part, i) =>
partHtml(part, i + 1) + part.files.map((doc) => fileHtml(doc, part, runningHeads)).join('')).join('');
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>okrdev — repository export</title>
<style>${fontFaceCss()}</style>
<style>${css}</style>
</head><body>
${coverHtml(meta)}
${aboutHtml(meta, dropped)}
${tocHtml(parts)}
${body}
</body></html>`;
}
/* ------------------------------------------------------------------ render -- */
const FOOTER = `<div style="width:100%;font-family:'DejaVu Sans',sans-serif;font-size:7.5px;
color:#949ca7;padding:0 22mm;display:flex;justify-content:space-between;align-items:center;">
<span>okrdev · repository export</span>
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>`;
async function renderPdf(html, outPath) {
const { chromium } = await loadPlaywright();
const browser = await launchChromium(chromium);
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
return await page.pdf({
path: outPath,
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate: FOOTER,
margin: {
top: `${PAGE.marginMm.top}mm`, bottom: `${PAGE.marginMm.bottom}mm`,
left: `${PAGE.marginMm.left}mm`, right: `${PAGE.marginMm.right}mm`,
},
outline: true,
tagged: true,
});
} finally {
await browser.close();
}
}
async function loadPlaywright() {
try { return await import('playwright'); } catch { /* fall back to a global install */ }
return import('/opt/node22/lib/node_modules/playwright/index.mjs');
}
/**
* Launch Chromium. Sandboxes that pre-install browsers (CI images, Claude Code
* on the web) often carry a build Playwright does not recognise, so fall back to
* whatever binary is actually on disk rather than asking for a download.
*/
async function launchChromium(chromium) {
const args = ['--font-render-hinting=none'];
try {
return await chromium.launch({ args });
} catch (err) {
const binary = findChromiumBinary();
if (!binary) throw err;
console.log(` using pre-installed Chromium at ${binary}`);
return chromium.launch({ args, executablePath: binary });
}
}
function findChromiumBinary() {
if (process.env.CHROMIUM_PATH && fs.existsSync(process.env.CHROMIUM_PATH)) {
return process.env.CHROMIUM_PATH;
}
const roots = [process.env.PLAYWRIGHT_BROWSERS_PATH, '/opt/pw-browsers'].filter(Boolean);
for (const root of roots) {
if (!fs.existsSync(root)) continue;
const candidates = [path.join(root, 'chromium')];
for (const entry of fs.readdirSync(root)) {
if (!entry.startsWith('chromium')) continue;
candidates.push(
path.join(root, entry, 'chrome-linux', 'chrome'),
path.join(root, entry, 'chrome-linux', 'headless_shell'),
);
}
const hit = candidates.find((c) => { try { return fs.statSync(c).isFile(); } catch { return false; } });
if (hit) return hit;
}
return null;
}
/** Map each marker token to the first PDF page it appears on. */
async function markerPages(pdfBuffer) {
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
const doc = await pdfjs.getDocument({
data: new Uint8Array(pdfBuffer),
disableFontFace: true,
isEvalSupported: false,
}).promise;
const found = new Map();
for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n);
const content = await page.getTextContent();
const text = content.items.map((it) => it.str || '').join('').replace(/\s+/g, '');
for (const m of text.matchAll(/PGMK[0-9A-Z]+Z/g)) {
if (!found.has(m[0])) found.set(m[0], n);
}
}
const pages = doc.numPages;
await doc.destroy();
return { found, pages };
}
function applyPageNumbers(html, markers, pageByMarker) {
return html.replace(/@@PG:([a-z0-9-]+)@@/g, (_, slug) => {
const marker = markers.get(slug);
const page = marker ? pageByMarker.get(marker) : null;
return page ? String(page) : '·';
});
}
/* -------------------------------------------------------------------- main -- */
async function main() {
const argv = process.argv.slice(2);
const outArg = argv.indexOf('--out');
const runningHeads = !argv.includes('--no-running-heads');
const withCode = argv.includes('--with-code');
const root = git(['rev-parse', '--show-toplevel'], process.cwd());
const outPath = path.resolve(root, outArg >= 0 ? argv[outArg + 1] : 'dist/okrdev-repo.pdf');
const { kept, dropped } = collectFiles(root, { withCode });
const parts = assignParts(kept);
// Build the document model first: every file needs a slug and a marker before
// links between them can be resolved.
const index = new Map();
const markers = new Map();
let counter = 0;
let lineCount = 0;
for (const part of parts) {
part.marker = `PGMK${String(counter++).padStart(4, '0')}Z`;
markers.set(`part-${part.id}`, part.marker);
part.files = part.files.map((p) => {
const abs = path.join(root, p);
const raw = fs.readFileSync(abs, 'utf8');
const doc = {
path: p,
abs,
slug: `f-${slugifyPath(p)}`,
marker: `PGMK${String(counter++).padStart(4, '0')}Z`,
kind: p.endsWith('.md') ? 'markdown' : 'listing',
title: p,
lines: raw.replace(/\n$/, '').split('\n').length,
};
lineCount += doc.lines;
index.set(p, doc);
markers.set(doc.slug, doc.marker);
return doc;
});
}
const corpus = [...index.values()].map((d) => fs.readFileSync(d.abs, 'utf8')).join('\n');
if (/PGMK\d+Z/.test(corpus)) throw new Error('marker token collides with repository content');
console.log(`Rendering ${index.size} files in ${parts.length} parts…`);
for (const part of parts) {
for (const doc of part.files) {
if (doc.kind === 'markdown') loadMarkdown(doc, index);
else loadListing(doc);
}
}
const remote = safeRemote(root);
const meta = {
remote,
branch: git(['rev-parse', '--abbrev-ref', 'HEAD'], root),
commit: git(['rev-parse', '--short', 'HEAD'], root),
commitDate: git(['log', '-1', '--format=%cs'], root),
generated: new Date().toISOString().slice(0, 10),
tagline: readTagline(root),
fileCount: index.size,
partCount: parts.length,
lineCount,
toolingPart: parts.findIndex((p) => p.id === 'tooling') + 1, // 0 when absent
};
const template = buildHtml({ parts, meta, dropped, runningHeads });
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const htmlArg = argv.indexOf('--html');
if (htmlArg >= 0) {
const htmlPath = path.resolve(root, argv[htmlArg + 1]);
fs.writeFileSync(htmlPath, applyPageNumbers(template, markers, new Map()));
console.log(` wrote ${path.relative(root, htmlPath)}`);
}
let pageByMarker = new Map();
let pages = 0;
for (let pass = 1; pass <= 3; pass++) {
const html = applyPageNumbers(template, markers, pageByMarker);
const buf = await renderPdf(html, outPath);
const result = await markerPages(buf);
pages = result.pages;
const stable = pass > 1 && [...result.found].every(([k, v]) => pageByMarker.get(k) === v);
pageByMarker = result.found;
console.log(` pass ${pass}: ${pages} pages, ${result.found.size}/${markers.size} markers located`);
if (stable) break;
if (pass === 3) console.warn(' ! contents page numbers did not settle after 3 passes');
}
const missing = [...markers.entries()].filter(([, m]) => !pageByMarker.has(m));
if (missing.length) console.warn(` ! no page found for ${missing.length} contents entries`);
const kb = Math.round(fs.statSync(outPath).size / 1024);
console.log(`\n${path.relative(root, outPath)} — ${pages} pages, ${kb} KB`);
}
function safeRemote(root) {
try {
const url = git(['config', '--get', 'remote.origin.url'], root);
const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
return m ? m[1] : url;
} catch { return 'okrdev'; }
}
function readTagline(root) {
const readme = path.join(root, 'README.md');
if (!fs.existsSync(readme)) return 'An OKR-obsessed operating framework.';
const m = fs.readFileSync(readme, 'utf8').match(/^\*\*(.+?)\*\*\s*$/m);
return m ? m[1] : 'An OKR-obsessed operating framework.';
}
main().catch((err) => { console.error(err); process.exit(1); });
SHA-256: caf99417edd446db1b0aec12fc8f52eb3c367a8dc476cac1688f7d3776a95c97