diff --git a/scripts/build-sdlc-delivery.sh b/scripts/build-sdlc-delivery.sh new file mode 100755 index 0000000..b1ad39d --- /dev/null +++ b/scripts/build-sdlc-delivery.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +TOOL_DIR="$SCRIPT_DIR/sdlc-delivery" +SOURCE_DIR="$ROOT_DIR/sdlc" +DELIVERY_ROOT="$ROOT_DIR/sdlc-delivery" +STAGING_DIR="$ROOT_DIR/.sdlc-delivery.staging" + +if [[ ! -d "$TOOL_DIR/node_modules/playwright" ]]; then + echo "Installing the local PDF renderer..." + npm install --prefix "$TOOL_DIR" + npx --prefix "$TOOL_DIR" playwright install chromium +fi + +rm -rf "$STAGING_DIR" +mkdir -p "$STAGING_DIR" + +while IFS= read -r -d '' source; do + relative="${source#"$ROOT_DIR/sdlc/"}" + destination="$STAGING_DIR/${relative%.md}.pdf" + echo "Rendering ${relative%.md}.pdf" + node "$TOOL_DIR/render-sdlc.mjs" "$source" "$destination" +done < <(find "$SOURCE_DIR" -type f -name '*.md' -print0 | sort -z) + +echo "Verifying staged delivery package..." +"$SCRIPT_DIR/verify-sdlc-delivery.sh" "$STAGING_DIR" + +rm -rf "$DELIVERY_ROOT" +mv "$STAGING_DIR" "$DELIVERY_ROOT" +echo "Delivery and verification passed: $DELIVERY_ROOT" diff --git a/scripts/sdlc-delivery/assets/LeelawadeeUI.ttf b/scripts/sdlc-delivery/assets/LeelawadeeUI.ttf new file mode 100644 index 0000000..ae5a126 Binary files /dev/null and b/scripts/sdlc-delivery/assets/LeelawadeeUI.ttf differ diff --git a/scripts/sdlc-delivery/assets/document-control.html b/scripts/sdlc-delivery/assets/document-control.html new file mode 100644 index 0000000..8f8a5f6 --- /dev/null +++ b/scripts/sdlc-delivery/assets/document-control.html @@ -0,0 +1,7 @@ + + + + + + +
Document{{documentType}}Release{{release}}
Project{{projectName}}Project code{{projectCode}}
Project period{{projectPeriod}}
diff --git a/scripts/sdlc-delivery/assets/footer.html b/scripts/sdlc-delivery/assets/footer.html new file mode 100644 index 0000000..080ca4c --- /dev/null +++ b/scripts/sdlc-delivery/assets/footer.html @@ -0,0 +1,4 @@ +
+ ISO/IEC 29110-4-1:2018 + {{projectCode}} +
diff --git a/scripts/sdlc-delivery/assets/header.html b/scripts/sdlc-delivery/assets/header.html new file mode 100644 index 0000000..46b96ed --- /dev/null +++ b/scripts/sdlc-delivery/assets/header.html @@ -0,0 +1,8 @@ +
+ +
+

B.R.N. ENTERPRISE CO., LTD.

+

1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120
Tel. (+66) 2687 0250   Fax. (+66) 2687 0255

+
+
{{documentTitle}}
+
diff --git a/scripts/sdlc-delivery/assets/preview.html b/scripts/sdlc-delivery/assets/preview.html new file mode 100644 index 0000000..caba88c --- /dev/null +++ b/scripts/sdlc-delivery/assets/preview.html @@ -0,0 +1,37 @@ + + + + + SDLC delivery theme preview + + + +
+
+ +
+

B.R.N. ENTERPRISE CO., LTD.

+

1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120
Tel. (+66) 2687 0250   Fax. (+66) 2687 0255

+
+
Software Project Plan
+
+ + + + + + + +
DocumentSoftware Project PlanRelease05/01/26 V1.0 Final
ProjectBRN WMSProject code200-WMS-26-001-00
Project period05/01/26–24/08/26
+ +

Project overview

+

This preview demonstrates the shared A4 header, document-control table, headings, and standard data tables used by every generated delivery PDF.

+ + + + +
PhasePeriodPrimary output
Planning12/01/26–18/02/26Project Plan
+
+ + + diff --git a/scripts/sdlc-delivery/assets/sdlc-delivery.css b/scripts/sdlc-delivery/assets/sdlc-delivery.css new file mode 100644 index 0000000..521062c --- /dev/null +++ b/scripts/sdlc-delivery/assets/sdlc-delivery.css @@ -0,0 +1,130 @@ +@page { + size: A4; + margin: 18mm 16mm 28mm; +} + +:root { + --brn-blue: #0789c9; + --brn-red: #bd0e2d; + --brn-ink: #1f2933; + --brn-muted: #52606d; + --brn-line: #aab7c4; + --brn-pale-blue: #eaf6fc; +} + +* { box-sizing: border-box; } + +html, body { + color: var(--brn-ink); + font-family: "BRN Thai", Arial, sans-serif; + font-size: 10.5pt; + line-height: 1.45; +} + +body { margin: 0; } + +.delivery-document { width: 100%; } + +.document-header { + align-items: center; + border-bottom: 1.5pt solid var(--brn-blue); + display: flex; + gap: 9mm; + margin: 0 0 7mm; + min-height: 22mm; + padding: 0 0 3mm; +} + +.document-header__logo { + flex: 0 0 auto; + height: 16mm; + object-fit: contain; + width: 16mm; +} + +.document-header__company { flex: 1; min-width: 0; } + +.document-header__company-name { + font-size: 13pt; + font-weight: 700; + letter-spacing: .03em; + margin: 0; +} + +.document-header__address { + color: var(--brn-muted); + font-size: 7.5pt; + line-height: 1.35; + margin: 1mm 0 0; +} + +.document-header__title { + background: var(--brn-blue); + color: #fff; + font-size: 15pt; + font-weight: 500; + flex: 0 1 72mm; + overflow-wrap: anywhere; + min-width: 52mm; + padding: 4mm 6mm; + text-align: center; +} + +.document-control { + border-collapse: collapse; + margin: 0 0 7mm; + page-break-inside: avoid; + width: 100%; +} + +.document-control th, +.document-control td { + border: .5pt solid var(--brn-line); + padding: 2.2mm 3mm; + text-align: left; + vertical-align: top; +} + +.document-control th { + background: var(--brn-pale-blue); + font-weight: 700; + width: 26%; +} + +h1, h2, h3 { break-after: avoid; color: var(--brn-ink); } +h1 { font-size: 18pt; margin: 0 0 5mm; } +h2 { border-left: 4pt solid var(--brn-blue); font-size: 14pt; margin: 9mm 0 4mm; padding-left: 3mm; } +h3 { color: var(--brn-blue); font-size: 11.5pt; margin: 6mm 0 3mm; } + +p { margin: 0 0 3.5mm; } + +table:not(.document-control) { + border-collapse: collapse; + border: .75pt solid #718096; + font-size: 9pt; + margin: 0 0 5mm; + table-layout: fixed; + width: 100%; +} + +table:not(.document-control) th, +table:not(.document-control) td { + border: .5pt solid var(--brn-line); + overflow-wrap: anywhere; + padding: 2mm; + vertical-align: top; + word-break: normal; +} + +table:not(.document-control) th { background: var(--brn-pale-blue); font-weight: 700; } +thead { display: table-header-group; } +tr { break-inside: avoid; } + +.approval-block { break-inside: avoid; margin-top: 9mm; } +.approval-block__person { margin: 5mm 0; min-height: 21mm; } +.approval-block__line { border-bottom: .5pt solid var(--brn-ink); display: inline-block; min-width: 70mm; } +.approval-fields { + line-height: 1.62; + margin: 0 0 8mm; + padding: 2.5mm 0 4mm; +} diff --git a/scripts/sdlc-delivery/package-lock.json b/scripts/sdlc-delivery/package-lock.json new file mode 100644 index 0000000..a6cea6f --- /dev/null +++ b/scripts/sdlc-delivery/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "brn-wms-sdlc-delivery", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "brn-wms-sdlc-delivery", + "dependencies": { + "playwright": "1.50.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz", + "integrity": "sha512-+GinGfGTrd2IfX1TA4N2gNmeIksSb+IAe589ZH+FlmpV3MYTx6+buChGIuDLQwrGNCw2lWibqV50fU510N7S+w==", + "dependencies": { + "playwright-core": "1.50.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.0.tgz", + "integrity": "sha512-CXkSSlr4JaZs2tZHI40DsZUN/NIwgaUPsyLuOAaIZp2CyF2sN5MM5NJsyB188lFSSozFxQ5fPT4qM+f0tH/6wQ==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/scripts/sdlc-delivery/package.json b/scripts/sdlc-delivery/package.json new file mode 100644 index 0000000..b426e48 --- /dev/null +++ b/scripts/sdlc-delivery/package.json @@ -0,0 +1,8 @@ +{ + "name": "brn-wms-sdlc-delivery", + "private": true, + "type": "module", + "dependencies": { + "playwright": "1.50.0" + } +} diff --git a/scripts/sdlc-delivery/render-sdlc.mjs b/scripts/sdlc-delivery/render-sdlc.mjs new file mode 100644 index 0000000..e1235eb --- /dev/null +++ b/scripts/sdlc-delivery/render-sdlc.mjs @@ -0,0 +1,175 @@ +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('<', '<') + .replaceAll('>', '>'); + +const renderInline = (value) => escapeHtml(value) + .replace(/\*\*(.+?)\*\*/g, '$1') + .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)); +} + +function renderTable(lines, className = '') { + const rows = lines.map(tableCells); + const header = rows[0]; + const data = isSeparator(rows[1] ?? []) ? rows.slice(2) : rows.slice(1); + const cells = (tag, row) => row.map((cell) => `<${tag}>${renderInline(cell)}`).join(''); + return `${cells('th', header)}${data.map((row) => `${cells('td', row)}`).join('')}`; +} + +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 ? '
' : (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(`${renderInline(heading[2])}`); + index += 1; + continue; + } + + if (/^[-*]\s+/.test(line)) { + const items = []; + while (index < lines.length && /^[-*]\s+/.test(lines[index])) items.push(`
  • ${renderInline(lines[index++].replace(/^[-*]\s+/, ''))}
  • `); + output.push(``); + continue; + } + + const paragraph = []; + while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index])) paragraph.push(lines[index++]); + const approvalClass = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd())) ? ' class="approval-fields"' : ''; + output.push(`${renderParagraph(paragraph)}

    `); + } + + 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: '', + totalPages: '' + }; + 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 = `
    ${applyTemplate(headerTemplate, values)}${applyTemplate(control, values)}${markdownToHtml(body)}
    `; + + 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: '
    ', + footerTemplate: pdfFooter + }); + } finally { + await browser.close(); + } +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/scripts/sdlc-delivery/verify-content.mjs b/scripts/sdlc-delivery/verify-content.mjs new file mode 100644 index 0000000..ae16976 --- /dev/null +++ b/scripts/sdlc-delivery/verify-content.mjs @@ -0,0 +1,91 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +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)); +} + +function extractDocumentContent(markdown) { + const titleMatch = markdown.match(/^#\s+.+\n+/m); + const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0)); + const lines = afterTitle.split('\n'); + + 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 dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1; + const fields = Object.fromEntries(table.slice(dataStart).map(tableCells).filter((row) => row.length >= 2)); + if (fields.Document && fields.Project) return { body: lines.slice(index).join('\n'), fields }; + index -= 1; + } + + return { body: afterTitle, fields: {} }; +} + +function words(text) { + return new Set((text.normalize('NFC').toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])); +} + +function compact(text) { + return text.normalize('NFC').toLocaleLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); +} + +async function markdownFiles(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const results = await Promise.all(entries.map(async (entry) => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) return markdownFiles(target); + return entry.isFile() && entry.name.endsWith('.md') ? [target] : []; + })); + return results.flat(); +} + +async function pdfText(pdf) { + const { stdout } = await execFileAsync('pdftotext', [pdf, '-'], { maxBuffer: 32 * 1024 * 1024 }); + return stdout; +} + +async function main() { + const [sourceRoot, deliveryRoot] = process.argv.slice(2); + if (!sourceRoot || !deliveryRoot) throw new Error('Usage: verify-content.mjs SOURCE_DIRECTORY DELIVERY_DIRECTORY'); + + const sources = (await markdownFiles(sourceRoot)).sort(); + const failures = []; + + for (const source of sources) { + const relative = path.relative(sourceRoot, source); + const pdf = path.join(deliveryRoot, relative.replace(/\.md$/, '.pdf')); + const [markdown, extracted] = await Promise.all([fs.readFile(source, 'utf8'), pdfText(pdf)]); + const { body, fields } = extractDocumentContent(markdown); + const controlText = ['Document', 'Project', 'Project code', 'Project period', 'Release'] + .map((field) => fields[field] ?? '') + .join('\n'); + const expected = words(`${body}\n${controlText}`); + const actual = compact(extracted); + const missing = [...expected].filter((word) => !actual.includes(word)); + if (missing.length) failures.push(`${relative}: missing ${missing.slice(0, 12).join(', ')}${missing.length > 12 ? ', …' : ''}`); + } + + if (failures.length) { + console.error(`Content coverage failed for ${failures.length}/${sources.length} PDFs:`); + failures.forEach((failure) => console.error(`- ${failure}`)); + process.exit(1); + } + + console.log(`Content coverage passed: ${sources.length}/${sources.length} PDFs.`); +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/scripts/verify-sdlc-delivery.sh b/scripts/verify-sdlc-delivery.sh new file mode 100755 index 0000000..9c113a9 --- /dev/null +++ b/scripts/verify-sdlc-delivery.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +SOURCE_DIR="$ROOT_DIR/sdlc" +TOOL_DIR="$SCRIPT_DIR/sdlc-delivery" +PACKAGE_DIR="$(realpath -m "${1:-$ROOT_DIR/sdlc-delivery}")" + +[[ -d "$PACKAGE_DIR" ]] || { echo "Delivery package not found: $PACKAGE_DIR" >&2; exit 2; } + +expected="$(find "$SOURCE_DIR" -type f -name '*.md' | wc -l | tr -d ' ')" +produced="$(find "$PACKAGE_DIR" -type f -name '*.pdf' | wc -l | tr -d ' ')" +[[ "$expected" == "$produced" ]] || { echo "Expected $expected PDFs, found $produced." >&2; exit 1; } + +node "$TOOL_DIR/verify-content.mjs" "$SOURCE_DIR" "$PACKAGE_DIR" + +format_errors=0 +while IFS= read -r -d '' pdf; do + page_size="$(pdfinfo "$pdf" | awk -F': *' '/^Page size/ {print $2}')" + case "$page_size" in + '594.96 x 841.92 pts (A4)'|'841.92 x 594.96 pts (A4)') ;; + *) echo "Non-A4 PDF: $pdf ($page_size)" >&2; format_errors=$((format_errors + 1));; + esac +done < <(find "$PACKAGE_DIR" -type f -name '*.pdf' -print0 | sort -z) + +[[ "$format_errors" == 0 ]] || exit 1 +echo "Format verification passed: $produced PDFs use A4 portrait or landscape."