391 lines
10 KiB
TypeScript
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);
|
||
|
|
});
|