Files
house-spec/build.ts
Juergen Kunz 6172e6c79c Initial house specification: air system, sensors, Lindner Doppelboden + Plafotherm ceiling
- 01: H13 HEPA whole-house air filtration, MVHR, duct design, pressure management
- 02: Sensor placement, automation logic, Home Assistant integration, wiring
- 03: Baufritz builder coordination, construction checkpoints, timeline
- 04: Lindner NORTEC Doppelboden with WOODline parquet + Plafotherm AirHybrid radiant ceiling
- Build system: tsx + marked + puppeteer, renders Mermaid diagrams to PDF
2026-03-08 18:58:22 +00:00

391 lines
10 KiB
TypeScript

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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
themeVariables: {
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
fontSize: '14px',
},
flowchart: { useMaxWidth: false, htmlLabels: true, curve: 'basis' },
block: { useMaxWidth: false },
});
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--color-primary: #1a3d5e;
--color-accent: #1a5e1a;
--color-danger: #8b0000;
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-muted: #6b7280;
--color-border: #e5e7eb;
--color-table-stripe: #f9fafb;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: var(--color-text);
background: var(--color-bg);
padding: 0;
}
.spec-section {
page-break-before: always;
}
.spec-section:first-child {
page-break-before: avoid;
}
/* Cover page */
.cover {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
text-align: center;
page-break-after: always;
}
.cover h1 {
font-size: 32pt;
font-weight: 700;
color: var(--color-primary);
margin-bottom: 12px;
letter-spacing: -0.5px;
}
.cover .subtitle {
font-size: 14pt;
color: var(--color-muted);
font-weight: 400;
margin-bottom: 48px;
}
.cover .meta {
font-size: 10pt;
color: var(--color-muted);
}
.cover .logo-line {
width: 80px;
height: 3px;
background: var(--color-primary);
margin: 24px auto;
}
/* Typography */
h1 {
font-size: 22pt;
font-weight: 700;
color: var(--color-primary);
margin: 32px 0 16px 0;
padding-bottom: 8px;
border-bottom: 2px solid var(--color-primary);
page-break-after: avoid;
}
h2 {
font-size: 16pt;
font-weight: 600;
color: var(--color-primary);
margin: 28px 0 12px 0;
page-break-after: avoid;
}
h3 {
font-size: 12pt;
font-weight: 600;
color: var(--color-text);
margin: 20px 0 8px 0;
page-break-after: avoid;
}
h4 {
font-size: 11pt;
font-weight: 600;
color: var(--color-muted);
margin: 16px 0 8px 0;
}
p {
margin: 8px 0;
}
strong {
font-weight: 600;
}
hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 24px 0;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0 20px 0;
font-size: 9.5pt;
page-break-inside: avoid;
}
thead {
background: var(--color-primary);
color: white;
}
th {
padding: 8px 12px;
text-align: left;
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 7px 12px;
border-bottom: 1px solid var(--color-border);
}
tbody tr:nth-child(even) {
background: var(--color-table-stripe);
}
/* Code */
code {
font-family: 'JetBrains Mono', monospace;
font-size: 9pt;
background: #f3f4f6;
padding: 2px 5px;
border-radius: 3px;
}
pre {
background: #f8f9fa;
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-primary);
padding: 14px 18px;
margin: 12px 0;
overflow-x: auto;
font-size: 9pt;
line-height: 1.5;
border-radius: 4px;
}
pre code {
background: none;
padding: 0;
}
/* Lists */
ul, ol {
margin: 8px 0;
padding-left: 24px;
}
li {
margin: 4px 0;
}
/* Blockquotes */
blockquote {
border-left: 4px solid var(--color-accent);
padding: 8px 16px;
margin: 12px 0;
background: #f0fdf4;
color: #166534;
font-style: italic;
}
/* Mermaid diagrams */
.mermaid {
display: flex;
justify-content: center;
margin: 20px 0;
page-break-inside: avoid;
}
.mermaid svg {
max-width: 100%;
height: auto !important;
}
/* Print styles */
@media print {
body { padding: 0; }
.cover { height: auto; min-height: 100vh; }
.spec-section { page-break-before: always; }
.mermaid svg { max-width: 100% !important; }
}
</style>
</head>
<body>
<div class="cover">
<h1>House Specification</h1>
<div class="logo-line"></div>
<div class="subtitle">Baufritz Build — Clean Air System</div>
<div class="meta">
<p>maintainable.xyz</p>
<p>Generated: ${new Date().toISOString().split('T')[0]}</p>
<p>Version 1.0</p>
</div>
</div>
${bodyHtml}
</body>
</html>`;
}
/**
* Extract mermaid code blocks from markdown, replacing them with unique
* placeholders. Returns the cleaned markdown and a map of placeholder → diagram.
* This prevents `marked` from corrupting the mermaid syntax (e.g. turning
* indented lines into <pre><code> blocks or mangling special characters).
*/
function extractMermaidBlocks(markdown: string): {
markdown: string;
diagrams: Map<string, string>;
} {
const diagrams = new Map<string, string>();
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 <div class="mermaid"> blocks containing the raw diagram source.
*/
function injectMermaidBlocks(html: string, diagrams: Map<string, string>): string {
let result = html;
for (const [id, diagram] of diagrams) {
// marked may have wrapped the placeholder in a <p> tag
const wrappedPattern = new RegExp(`<p>${id}</p>`, 'g');
const rawPattern = new RegExp(id, 'g');
const replacement = `<div class="mermaid">\n${diagram}\n</div>`;
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 `<div class="${sectionClass}">\n\n${spec.content}\n\n</div>`;
})
.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: `
<div style="font-size: 8pt; color: #9ca3af; width: 100%; padding: 0 18mm; font-family: system-ui;">
<span style="float: left;">maintainable.xyz — House Specification</span>
<span style="float: right;">Baufritz Build</span>
</div>`,
footerTemplate: `
<div style="font-size: 8pt; color: #9ca3af; width: 100%; text-align: center; font-family: system-ui;">
<span class="pageNumber"></span> / <span class="totalPages"></span>
</div>`,
});
await browser.close();
console.log(`PDF generated: ${OUTPUT_FILE}`);
}
main().catch(err => {
console.error('Build failed:', err);
process.exit(1);
});