docs(sdlc): reformat work products to audited reference layout

Replace the PHP/Markdown seeding with a data-driven generator in
scripts/sdlc-seed and regenerate all 59 work products.
This commit is contained in:
Thanakorn
2026-09-09 08:09:54 +07:00
parent 758922f5c4
commit 087e7b3958
209 changed files with 8063 additions and 5762 deletions
@@ -1,7 +0,0 @@
<table class="document-control">
<tbody>
<tr><th>Document</th><td>{{documentType}}</td><th>Release</th><td>{{release}}</td></tr>
<tr><th>Project</th><td>{{projectName}}</td><th>Project code</th><td>{{projectCode}}</td></tr>
<tr><th>Project period</th><td colspan="3">{{projectPeriod}}</td></tr>
</tbody>
</table>
+1 -1
View File
@@ -1,4 +1,4 @@
<div style="border-top:1.5pt solid #1f2933; color:#52606d; display:flex; font-family:'BRN Thai',Arial,sans-serif; font-size:8pt; justify-content:space-between; margin:0 16mm; padding-top:2.5mm; width:calc(100% - 32mm);">
<span>ISO/IEC 29110-4-1:2018</span>
<span>{{footerNote}}</span>
<span>{{projectCode}}</span>
</div>
@@ -160,3 +160,19 @@ tr { break-inside: avoid; }
margin: 0;
padding: 0;
}
/* Signature blocks: heading and its table stay together, in the reference layout. */
.signature-block { break-inside: avoid; margin: 0 0 5mm; }
.signature-block h2.signature-heading { border-left: none; font-size: 11pt; margin: 5mm 0 2mm; padding-left: 0; }
/* Fixed columns so the signature grid always fits the text block width. */
table.signature-table { table-layout: fixed; }
table.signature-table td { height: 13mm; }
table.signature-table th.tight,
table.signature-table td.tight { white-space: normal; width: auto; }
table.signature-table th:nth-child(1), table.signature-table td:nth-child(1) { width: 32%; }
table.signature-table th:nth-child(2), table.signature-table td:nth-child(2) { width: 26%; }
table.signature-table th:nth-child(3), table.signature-table td:nth-child(3) { width: 22%; }
table.signature-table th:nth-child(4), table.signature-table td:nth-child(4) { width: 20%; }
/* The document-control block repeats its label column, so labels must not stretch. */
.document-control th { width: 1%; }
+54 -29
View File
@@ -13,6 +13,9 @@ const escapeHtml = (value) => value
.replaceAll('>', '&gt;');
const renderInline = (value) => escapeHtml(value)
// A literal <br> is the only markup allowed through, so a table cell can list
// several files on separate lines the way the reference package does.
.replaceAll('&lt;br&gt;', '<br>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/_{3,}/g, '<span class="approval-block__line"></span>')
@@ -44,6 +47,23 @@ function columnAlignment(separatorCells) {
});
}
function isControlBlock(lines) {
return tableCells(lines[0] ?? '')[0] === 'Document No';
}
// The document-control block is a label/value grid, not a data table: odd cells are
// labels, even cells are values, and a row with one pair spans the full width.
function renderControlBlock(lines) {
const rows = lines.map(tableCells);
const body = rows.map((cells) => {
const pairs = [];
for (let i = 0; i < cells.length; i += 2) pairs.push([cells[i], cells[i + 1] ?? '']);
const span = pairs.length === 1 ? ' colspan="3"' : '';
return `<tr>${pairs.map(([label, value]) => `<th>${renderInline(label)}</th><td${span}>${renderInline(value)}</td>`).join('')}</tr>`;
}).join('');
return `<table class="document-control"><tbody>${body}</tbody></table>`;
}
function renderTable(lines, className = '') {
const rows = lines.map(tableCells);
const header = rows[0];
@@ -92,6 +112,13 @@ function markdownToHtml(markdown) {
if (isTableLine(line)) {
const table = [];
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
if (isControlBlock(table)) { output.push(renderControlBlock(table)); continue; }
const previous = output[output.length - 1] ?? '';
// Keep a signature heading and its table together on one page.
if (/^<h2 class="signature-heading">/.test(previous)) {
output.push(`<div class="signature-block">${output.pop()}${renderTable(table, 'signature-table')}</div>`);
continue;
}
output.push(renderTable(table));
continue;
}
@@ -99,7 +126,9 @@ function markdownToHtml(markdown) {
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
output.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
const signature = level === 2 && /^ผู้(จัดทำเอกสาร|ตรวจสอบเอกสาร|อนุมัติ)/.test(heading[2]);
const attr = signature ? ' class="signature-heading"' : '';
output.push(`<h${level}${attr}>${renderInline(heading[2])}</h${level}>`);
index += 1;
continue;
}
@@ -136,32 +165,26 @@ function markdownToHtml(markdown) {
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;
const footer = markdown.match(/^<!--\s*footer:\s*(.+?)\s*-->\s*$/m)?.[1] ?? '';
const body = markdown
.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0))
.replace(/^<!--\s*footer:.*?-->\s*$/m, '');
// The control block carries the document identity used by the header and footer.
const metadata = {};
const lines = body.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 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 (!isControlBlock(table)) { index -= 1; continue; }
for (const cells of table.map(tableCells)) {
for (let i = 0; i < cells.length; i += 2) if (cells[i]) metadata[cells[i]] = cells[i + 1] ?? '';
}
if (candidate.Document && candidate.Project) {
Object.assign(metadata, candidate);
bodyStart = index;
break;
}
index -= 1;
break;
}
return { title, metadata, body: lines.slice(bodyStart).join('\n') };
return { title, footer, metadata, body };
}
async function main() {
@@ -176,27 +199,29 @@ async function main() {
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 { title, footer, metadata, body } = extractDocument(markdown);
// Wide reference tables (schedule, register, test cases, traceability) need landscape;
// ordinary 8-column status tables still read well in portrait.
const widestTable = Math.max(0, ...markdown.split('\n').filter(isTableLine).map((line) => tableCells(line).length));
const LANDSCAPE_TITLES = new Set(['Work Schedule', 'Traceability Record', 'Traceability Record Table']);
const isLandscape = LANDSCAPE_TITLES.has(title) || widestTable >= 9;
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',
documentType: metadata['Document No'] ?? title,
release: metadata['Release, Version, By:'] ?? '',
projectName: metadata['Project Name'] ?? 'BRN WMS',
projectCode: metadata['Project Code'] ?? '200-WMS-26-001-00',
footerNote: footer ? `ISO/IEC 29110-4-1:2018 ${footer}` : 'ISO/IEC 29110-4-1:2018',
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>`;
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${applyTemplate(headerTemplate, values)}${markdownToHtml(body)}</main></body></html>`;
await fs.mkdir(path.dirname(destination), { recursive: true });
const browser = await chromium.launch({ headless: true });
+6 -19
View File
@@ -19,20 +19,10 @@ function isSeparator(cells) {
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: {} };
const body = markdown
.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0))
.replace(/^<!--\s*footer:.*?-->\s*$/m, '');
return { body, fields: {} };
}
function words(text) {
@@ -69,11 +59,8 @@ async function main() {
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 { body } = extractDocumentContent(markdown);
const expected = words(body);
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 ? ', …' : ''}`);