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:
@@ -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,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%; }
|
||||
|
||||
@@ -13,6 +13,9 @@ const escapeHtml = (value) => value
|
||||
.replaceAll('>', '>');
|
||||
|
||||
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('<br>', '<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 });
|
||||
|
||||
@@ -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 ? ', …' : ''}`);
|
||||
|
||||
Reference in New Issue
Block a user