Files
web-frontend/docs/scripts/build-typst-tz.mjs
2026-05-04 10:38:35 +07:00

325 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 { 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 = /^<!--@include:\s+(.+?)\s*-->$/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('<br>', ' ');
text = text.replaceAll('<br/>', ' ');
text = text.replaceAll('&nbsp;', ' ');
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],
[Формат], [Техническое задание с учетом ГОСТ 19.201-78],
)
]
#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`);