Files
wms-app/scripts/sdlc-delivery/render-sdlc.mjs
T

221 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '../..');
const assets = path.join(here, 'assets');
const escapeHtml = (value) => value
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
const renderInline = (value) => escapeHtml(value)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/_{3,}/g, '<span class="approval-block__line"></span>')
.replace(/\\([\\*_`~])/g, '$1');
const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
function isTableLine(line) {
return /^\|.*\|\s*$/.test(line.trim());
}
function tableCells(line) {
return line.trim().slice(1, -1).split('|').map((cell) => cell.trim());
}
function isSeparator(cells) {
return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
}
const TIGHT_COLUMN_MAX = 12;
function columnAlignment(separatorCells) {
return separatorCells.map((cell) => {
const left = cell.startsWith(':');
const right = cell.endsWith(':');
if (left && right) return 'a-center';
if (right) return 'a-right';
return '';
});
}
function renderTable(lines, className = '') {
const rows = lines.map(tableCells);
const header = rows[0];
const hasSeparator = isSeparator(rows[1] ?? []);
const align = hasSeparator ? columnAlignment(rows[1]) : header.map(() => '');
const data = hasSeparator ? rows.slice(2) : rows.slice(1);
// A column whose longest cell is short (IDs, dates, counts, statuses) is
// marked "tight" so auto table layout shrinks it to its content instead of
// handing every column an equal share of the page width.
const widest = header.map((_, column) => Math.max(
(header[column] ?? '').length,
...data.map((row) => (row[column] ?? '').replace(/[`*]/g, '').length)
));
const classFor = (column) => [
align[column] ?? '',
widest[column] <= TIGHT_COLUMN_MAX ? 'tight' : ''
].filter(Boolean).join(' ');
const cells = (tag, row) => row.map((cell, column) => {
const attr = classFor(column);
return `<${tag}${attr ? ` class="${attr}"` : ''}>${renderInline(cell)}</${tag}>`;
}).join('');
return `<table${className ? ` class="${className}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
}
function renderParagraph(lines) {
return lines.map((line, index) => {
const trimmed = line.trimEnd();
const hasHardBreak = /\s{2,}$/.test(line);
const isApprovalField = /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(trimmed);
const separator = hasHardBreak || isApprovalField ? '<br>' : (index < lines.length - 1 ? ' ' : '');
return `${renderInline(trimmed)}${separator}`;
}).join('');
}
function markdownToHtml(markdown) {
const lines = markdown.replaceAll('\r\n', '\n').split('\n');
const output = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) { index += 1; continue; }
if (isTableLine(line)) {
const table = [];
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
output.push(renderTable(table));
continue;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
output.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
index += 1;
continue;
}
if (/^[-*]\s+/.test(line)) {
const items = [];
while (index < lines.length && /^[-*]\s+/.test(lines[index])) items.push(`<li>${renderInline(lines[index++].replace(/^[-*]\s+/, ''))}</li>`);
output.push(`<ul>${items.join('')}</ul>`);
continue;
}
if (/^\d+\.\s+/.test(line)) {
const items = [];
while (index < lines.length && /^\d+\.\s+/.test(lines[index])) items.push(`<li>${renderInline(lines[index++].replace(/^\d+\.\s+/, ''))}</li>`);
output.push(`<ol>${items.join('')}</ol>`);
continue;
}
const paragraph = [];
while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index]) && !/^\d+\.\s+/.test(lines[index])) paragraph.push(lines[index++]);
const isApproval = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd()));
const html = `<p${isApproval ? ' class="approval-fields"' : ''}>${renderParagraph(paragraph)}</p>`;
// Keep a signatory's heading and fields on one page.
if (isApproval && /^<h3>/.test(output[output.length - 1] ?? '')) {
output.push(`<div class="approval-block__person">${output.pop()}${html}</div>`);
} else {
output.push(html);
}
}
return output.join('\n');
}
function extractDocument(markdown) {
const titleMatch = markdown.match(/^#\s+(.+)\n+/m);
const title = titleMatch?.[1] ?? 'BRN WMS';
const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0));
const lines = afterTitle.split('\n');
const metadata = {};
let bodyStart = 0;
for (let index = 0; index < lines.length; index += 1) {
if (!isTableLine(lines[index])) continue;
const table = [];
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
const candidate = {};
const dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1;
for (const row of table.slice(dataStart).map(tableCells)) {
if (row.length >= 2) candidate[row[0]] = row[1];
}
if (candidate.Document && candidate.Project) {
Object.assign(metadata, candidate);
bodyStart = index;
break;
}
index -= 1;
}
return { title, metadata, body: lines.slice(bodyStart).join('\n') };
}
async function main() {
const [source, destination] = process.argv.slice(2);
if (!source || !destination) throw new Error('Usage: render-sdlc.mjs SOURCE.md DESTINATION.pdf');
const [markdown, css, headerTemplate, footerTemplate, logo, thaiFont] = await Promise.all([
fs.readFile(source, 'utf8'),
fs.readFile(path.join(assets, 'sdlc-delivery.css'), 'utf8'),
fs.readFile(path.join(assets, 'header.html'), 'utf8'),
fs.readFile(path.join(assets, 'footer.html'), 'utf8'),
fs.readFile(path.join(root, 'app/assets/images/logo.svg')),
fs.readFile(path.join(assets, 'LeelawadeeUI.ttf'))
]);
const { title, metadata, body } = extractDocument(markdown);
const hasWideTable = markdown.split('\n').some((line) => isTableLine(line) && tableCells(line).length >= 8);
const isLandscape = metadata.Document === 'Work Schedule' || title === 'Work Schedule' || hasWideTable;
const values = {
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
documentTitle: title,
documentType: metadata.Document ?? title,
release: metadata.Release ?? '',
projectName: metadata.Project ?? 'BRN WMS',
projectCode: metadata['Project code'] ?? '200-WMS-26-001-00',
projectPeriod: metadata['Project period'] ?? '05/01/26–24/08/26',
pageNumber: '<span class="pageNumber"></span>',
totalPages: '<span class="totalPages"></span>'
};
const control = await fs.readFile(path.join(assets, 'document-control.html'), 'utf8');
const pdfFooter = applyTemplate(footerTemplate, values);
const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${thaiFont.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
const pageLayout = isLandscape
? '@page { size: A4 landscape; margin: 18mm 16mm 28mm; }'
: '';
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${applyTemplate(headerTemplate, values)}${applyTemplate(control, values)}${markdownToHtml(body)}</main></body></html>`;
await fs.mkdir(path.dirname(destination), { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
await page.pdf({
path: destination,
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate: pdfFooter
});
} finally {
await browser.close();
}
}
main().catch((error) => { console.error(error); process.exit(1); });