315 lines
8.2 KiB
JavaScript
315 lines
8.2 KiB
JavaScript
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(']', '\\]');
|
||
}
|
||
|
||
function inlineTypst(value) {
|
||
let text = value.trim();
|
||
|
||
text = text.replaceAll('<br>', ' ');
|
||
text = text.replaceAll('<br/>', ' ');
|
||
text = text.replaceAll(' ', ' ');
|
||
text = text.replace(/\*\*([^*]+)\*\*/g, '*$1*');
|
||
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: "Arial", size: 10pt, lang: "ru")
|
||
#set par(justify: true, leading: 0.58em, first-line-indent: 0pt)
|
||
#set list(indent: 13pt, body-indent: 5pt)
|
||
#show link: underline
|
||
#show heading.where(level: 1): set text(size: 15pt, weight: "bold")
|
||
#show heading.where(level: 2): set text(size: 12pt, weight: "bold")
|
||
#show heading.where(level: 3): set text(size: 10.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`);
|