import { readdir, readFile, mkdir } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { Marked } from 'marked'; import puppeteer from 'puppeteer'; const SPECS_DIR = resolve('specs'); const DIST_DIR = resolve('dist'); const OUTPUT_FILE = join(DIST_DIR, 'house-spec.pdf'); async function loadSpecs(): Promise<{ filename: string; content: string }[]> { const files = await readdir(SPECS_DIR); const mdFiles = files.filter(f => f.endsWith('.md')).sort(); const specs: { filename: string; content: string }[] = []; for (const file of mdFiles) { const content = await readFile(join(SPECS_DIR, file), 'utf-8'); specs.push({ filename: file, content }); } return specs; } function buildHtml(bodyHtml: string): string { return `
blocks or mangling special characters).
*/
function extractMermaidBlocks(markdown: string): {
markdown: string;
diagrams: Map;
} {
const diagrams = new Map();
let index = 0;
const cleaned = markdown.replace(
/```mermaid\n([\s\S]*?)```/g,
(_match, diagram: string) => {
const id = `MERMAIDPLACEHOLDER${index++}END`;
diagrams.set(id, diagram.trim());
return id;
},
);
return { markdown: cleaned, diagrams };
}
/**
* Re-inject mermaid diagrams into the HTML, replacing placeholders
* with blocks containing the raw diagram source.
*/
function injectMermaidBlocks(html: string, diagrams: Map): string {
let result = html;
for (const [id, diagram] of diagrams) {
// marked may have wrapped the placeholder in a tag
const wrappedPattern = new RegExp(`
${id}
`, 'g');
const rawPattern = new RegExp(id, 'g');
const replacement = `\n${diagram}\n`;
result = result.replace(wrappedPattern, replacement);
result = result.replace(rawPattern, replacement);
}
return result;
}
async function main() {
console.log('Loading specs...');
const specs = await loadSpecs();
console.log(`Found ${specs.length} spec files.`);
// Combine specs into raw markdown first
let rawCombined = specs
.map((spec, i) => {
const sectionClass = i === 0 ? 'spec-section first' : 'spec-section';
return `\n\n${spec.content}\n\n`;
})
.join('\n\n');
// Extract all mermaid blocks from the combined markdown (single pass, unique IDs)
const { markdown: combinedMarkdown, diagrams: allDiagrams } = extractMermaidBlocks(rawCombined);
console.log(`Extracted ${allDiagrams.size} mermaid diagrams.`);
// Parse markdown to HTML — mermaid blocks are now just placeholder strings
const marked = new Marked();
let bodyHtml = await marked.parse(combinedMarkdown);
// Re-inject raw mermaid diagrams into the HTML
bodyHtml = injectMermaidBlocks(bodyHtml, allDiagrams);
const fullHtml = buildHtml(bodyHtml);
// Ensure dist directory exists
await mkdir(DIST_DIR, { recursive: true });
// Write intermediate HTML for debugging
const htmlPath = join(DIST_DIR, 'house-spec.html');
const { writeFile } = await import('node:fs/promises');
await writeFile(htmlPath, fullHtml, 'utf-8');
console.log(`HTML written to: ${htmlPath}`);
// Launch puppeteer and render PDF
console.log('Launching browser...');
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
// Set content and wait for mermaid to render
await page.setContent(fullHtml, { waitUntil: 'networkidle0' });
// Wait for mermaid diagrams to render
await page.evaluate(async () => {
// Check if mermaid is loaded
if (typeof (window as any).mermaid !== 'undefined') {
await (window as any).mermaid.run();
}
});
// Small delay to ensure all SVGs are rendered
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('Generating PDF...');
await page.pdf({
path: OUTPUT_FILE,
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
bottom: '20mm',
left: '18mm',
right: '18mm',
},
displayHeaderFooter: true,
headerTemplate: `
maintainable.xyz — House Specification
Baufritz Build
`,
footerTemplate: `
/
`,
});
await browser.close();
console.log(`PDF generated: ${OUTPUT_FILE}`);
}
main().catch(err => {
console.error('Build failed:', err);
process.exit(1);
});