import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; // Usage: node docs/scripts/build-typst-tz.mjs // Output: docs/export/tz-fregat.typ and docs/export/tz-fregat.pdf const __dirname = dirname(fileURLToPath(import.meta.url)); const docsDir = join(__dirname, '..'); const sourceFile = join(docsDir, 'index.md'); const exportDir = join(docsDir, 'export'); const typstFile = join(exportDir, 'tz-fregat.typ'); const pdfFile = join(exportDir, 'tz-fregat.pdf'); const includePattern = /^$/gm; async function readMarkdownWithIncludes(filePath, seen = new Set()) { if (seen.has(filePath)) { throw new Error(`Circular include detected: ${filePath}`); } seen.add(filePath); let content = await readFile(filePath, 'utf8'); const replacements = []; for (const match of content.matchAll(includePattern)) { const includePath = join(dirname(filePath), match[1]); const includeContent = await readMarkdownWithIncludes(includePath, seen); replacements.push([match[0], includeContent.trim()]); } for (const [needle, replacement] of replacements) { content = content.replace(needle, replacement); } seen.delete(filePath); return content; } function escapeTypstString(value) { return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); } function escapeContentBlock(value) { return value .replaceAll('\\', '\\\\') .replaceAll('[', '\\[') .replaceAll(']', '\\]') .replaceAll('*', '\\*') .replaceAll('#', '\\#') .replaceAll('@', '\\@'); } function inlineTypst(value) { let text = value.trim(); text = text.replaceAll('
', ' '); text = text.replaceAll('
', ' '); text = text.replaceAll(' ', ' '); text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); text = text.replace(/`([^`]+)`/g, '$1'); text = text.replaceAll('*', '\\*'); text = text.replaceAll('@', '\\@'); text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, href) => { if (href.startsWith('http://') || href.startsWith('https://')) { return `#link("${escapeTypstString(href)}")[${escapeContentBlock(label)}]`; } return label; }); return text; } function parseTableRow(line) { const trimmed = line.trim(); const withoutEdges = trimmed.replace(/^\|/, '').replace(/\|$/, ''); return withoutEdges.split('|').map((cell) => inlineTypst(cell)); } function isTableSeparator(line) { return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); } function renderTable(lines, startIndex) { const rows = []; let index = startIndex; while (index < lines.length && /^\s*\|/.test(lines[index])) { if (!isTableSeparator(lines[index])) { rows.push(parseTableRow(lines[index])); } index += 1; } const columnCount = Math.max(...rows.map((row) => row.length)); const normalizedRows = rows.map((row) => { if (row.length >= columnCount) { return row; } return [...row, ...Array.from({ length: columnCount - row.length }, () => '')]; }); const cells = normalizedRows.flatMap((row, rowIndex) => row.map((cell) => { const content = escapeContentBlock(cell); return rowIndex === 0 ? `[*${content}*]` : `[${content}]`; })); const columns = Array.from({ length: columnCount }, () => '1fr').join(', '); return { nextIndex: index, typst: [ '#text(size: 7.6pt)[', ' #table(', ` columns: (${columns}),`, ' stroke: rgb("#d0d7de"),', ' inset: 4pt,', ' align: horizon + left,', ` ${cells.join(',\n ')}`, ' )', ']', '', ].join('\n'), }; } function renderImage(line) { const match = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); if (!match) { return null; } const [, alt, src] = match; const imagePath = src.startsWith('/') ? `..${src.replace(/^\//, '/public/')}` : src; return [ '#figure(', ` image("${escapeTypstString(imagePath)}", width: 100%),`, ` caption: [${escapeContentBlock(alt)}],`, ')', '', ].join('\n'); } function markdownToTypst(markdown) { const lines = markdown .replace(/\r\n/g, '\n') .replace(/^---[\s\S]*?---\n/, '') .split('\n'); const output = []; let inFence = false; for (let index = 0; index < lines.length;) { const line = lines[index]; const trimmed = line.trim(); if (trimmed.startsWith('```')) { inFence = !inFence; output.push(line); index += 1; continue; } if (inFence) { output.push(line); index += 1; continue; } if (!trimmed) { output.push(''); index += 1; continue; } if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableSeparator(lines[index + 1])) { const table = renderTable(lines, index); output.push(table.typst); index = table.nextIndex; continue; } const image = renderImage(trimmed); if (image) { output.push(image); index += 1; continue; } const heading = line.match(/^(#{1,6})\s+(.+)$/); if (heading) { const level = heading[1].length; const title = inlineTypst(heading[2]); if (level === 1 && title === 'Техническое задание на разработку программного продукта') { index += 1; continue; } if (level === 1 && output.some((item) => item.trim())) { output.push('#pagebreak(weak: true)'); } output.push(`${'='.repeat(level)} ${title}`); output.push(''); index += 1; continue; } const bullet = line.match(/^(\s*)-\s+(.+)$/); if (bullet) { output.push(`${bullet[1]}- ${inlineTypst(bullet[2])}`); index += 1; continue; } const ordered = line.match(/^(\s*)\d+\.\s+(.+)$/); if (ordered) { output.push(`${ordered[1]}+ ${inlineTypst(ordered[2])}`); index += 1; continue; } output.push(inlineTypst(line)); index += 1; } return output.join('\n'); } function typstDocument(body) { return `#set document(title: "Техническое задание на разработку программного продукта") #set page( paper: "a4", margin: (left: 20mm, right: 18mm, top: 18mm, bottom: 18mm), numbering: "1", header: align(right)[#text(size: 8pt, fill: rgb("#667085"))[Личный кабинет Фрегат]], footer: align(center)[#text(size: 8pt, fill: rgb("#667085"))[#context counter(page).display("1")]], ) #set text(font: "Times New Roman", size: 10.5pt, lang: "ru") #set par(justify: true, leading: 0.58em, first-line-indent: 0pt) #set list(indent: 13pt, body-indent: 5pt) #show link: underline #show raw: set text(font: "Times New Roman") #show heading.where(level: 1): set text(size: 16pt, weight: "bold") #show heading.where(level: 2): set text(size: 13pt, weight: "bold") #show heading.where(level: 3): set text(size: 11.5pt, weight: "bold") #show heading: it => block(above: 1.15em, below: 0.55em, it) #align(center)[ #text(size: 18pt, weight: "bold")[Техническое задание] #v(5mm) #text(size: 14pt)[на разработку программного продукта] #v(3mm) #text(size: 14pt, weight: "bold")[«Личный кабинет Фрегат»] ] #v(12mm) #align(center)[ #table( columns: (40%, 60%), stroke: rgb("#d0d7de"), inset: 6pt, [Заказчик], [ООО «Фрегат Групп»], [Исполнитель], [ИП Бакиев Р.Ш.], [Основание], [Договор №28/04-26ПО от 28.04.2026], [Формат], [Техническое задание], ) ] #pagebreak() #outline(title: [Содержание], depth: 2, indent: auto) #pagebreak() ${body} `; } await mkdir(exportDir, { recursive: true }); const markdown = await readMarkdownWithIncludes(sourceFile); const typst = typstDocument(markdownToTypst(markdown)); await writeFile(typstFile, typst, 'utf8'); const compileResult = spawnSync('typst', [ 'compile', '--root', '.', relative(docsDir, typstFile), relative(docsDir, pdfFile), ], { cwd: docsDir, encoding: 'utf8', }); if (compileResult.status !== 0) { process.stderr.write(compileResult.stderr); process.stderr.write(compileResult.stdout); throw new Error('Typst PDF build failed'); } process.stdout.write(`Generated ${relative(docsDir, typstFile)}\n`); process.stdout.write(`Generated ${relative(docsDir, pdfFile)}\n`);