146 lines
4.9 KiB
JavaScript
146 lines
4.9 KiB
JavaScript
import fs from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import process from 'node:process';
|
||
import matter from 'gray-matter';
|
||
import { parseDoc, validateDoc, serializeDoc } from './md.js';
|
||
import { optimizeImage, hashFile } from './images.js';
|
||
|
||
const ROOT = process.cwd();
|
||
const CONTENT = path.join(ROOT, 'content');
|
||
const OUT = path.join(ROOT, 'out');
|
||
|
||
const cfg = JSON.parse(await fs.readFile(path.join(ROOT, 'config.json'), 'utf8'));
|
||
if (cfg.repo === 'USER/REPO') {
|
||
console.error('Заполните config.json: поле "repo" (например "ivanov/panel-content").');
|
||
process.exit(1);
|
||
}
|
||
|
||
// CONTENT_SHA задаёт CI; локально используем ветку (TTL кэша jsDelivr у ветки больше).
|
||
const sha = process.env.CONTENT_SHA ?? cfg.branch;
|
||
const CDN = `https://cdn.jsdelivr.net/gh/${cfg.repo}@${sha}`;
|
||
|
||
const errors = [];
|
||
const imageCache = new Map();
|
||
|
||
function report(file, pos, hint) {
|
||
errors.push({ file, line: pos?.line ?? 0, column: pos?.column ?? 0, hint });
|
||
}
|
||
|
||
// Относительные пути картинок резолвятся от .md-файла, оптимизируются sharp-ом
|
||
// и переписываются на абсолютный URL (jsDelivr @sha).
|
||
async function resolveImage(src, { absPath, id, pos }) {
|
||
if (!src) return null;
|
||
if (/^https?:\/\//i.test(src)) {
|
||
if (!/^https:\/\//i.test(src)) {
|
||
report(absPath, pos, `внешняя картинка должна быть https: ${src}`);
|
||
return null;
|
||
}
|
||
return src;
|
||
}
|
||
const abs = path.resolve(path.dirname(absPath), src);
|
||
if (!abs.startsWith(path.join(CONTENT, path.sep))) {
|
||
report(absPath, pos, `путь картинки вне content/: ${src}`);
|
||
return null;
|
||
}
|
||
try {
|
||
await fs.access(abs);
|
||
} catch {
|
||
report(absPath, pos, `файл картинки не найден: ${src}`);
|
||
return null;
|
||
}
|
||
const hash = await hashFile(abs);
|
||
const name = `${id}-${hash}.webp`;
|
||
if (!imageCache.has(name)) {
|
||
await optimizeImage(abs, path.join(OUT, 'img', name), { width: 640 });
|
||
imageCache.set(name, true);
|
||
}
|
||
return `${CDN}/img/${name}`;
|
||
}
|
||
|
||
async function main() {
|
||
await fs.rm(OUT, { recursive: true, force: true });
|
||
await fs.mkdir(path.join(OUT, 'docs'), { recursive: true });
|
||
|
||
const docsDir = path.join(CONTENT, 'docs');
|
||
const files = (await fs.readdir(docsDir)).filter((f) => f.endsWith('.md'));
|
||
if (!files.length) {
|
||
console.error('В content/docs/ нет .md файлов.');
|
||
process.exit(1);
|
||
}
|
||
|
||
const index = [];
|
||
for (const fileName of files) {
|
||
const absPath = path.join(docsDir, fileName);
|
||
const id = fileName.replace(/\.md$/, '');
|
||
const { data, content } = matter(await fs.readFile(absPath, 'utf8'));
|
||
|
||
if (typeof data.title !== 'string' || !data.title.trim()) {
|
||
report(absPath, null, 'front-matter: обязательно поле title (строка)');
|
||
continue;
|
||
}
|
||
|
||
const ast = parseDoc(content);
|
||
errors.push(
|
||
...validateDoc(ast, fileName).map((e) => ({
|
||
file: e.file,
|
||
line: e.line,
|
||
column: e.column,
|
||
hint: `${e.type}: ${e.hint}`,
|
||
})),
|
||
);
|
||
if (errors.some((e) => e.file === fileName)) continue;
|
||
|
||
const blocks = await serializeDoc(ast, {
|
||
resolveImage: (src, pos) => resolveImage(src, { absPath, id, pos }),
|
||
});
|
||
|
||
// Заголовочная картинка: относительный путь от content/assets, кроп 636×340 (318×170 @2x).
|
||
let header = null;
|
||
if (data.header) {
|
||
const headerPath = path.resolve(CONTENT, 'assets', data.header);
|
||
try {
|
||
await fs.access(headerPath);
|
||
} catch {
|
||
report(absPath, null, `front-matter header: файл не найден content/assets/${data.header}`);
|
||
continue;
|
||
}
|
||
const name = `${id}-header.webp`;
|
||
await optimizeImage(headerPath, path.join(OUT, 'img', name), {
|
||
width: 636,
|
||
height: 340,
|
||
});
|
||
header = `${CDN}/img/${name}`;
|
||
}
|
||
|
||
await fs.writeFile(
|
||
path.join(OUT, 'docs', `${id}.json`),
|
||
JSON.stringify({ id, title: data.title, header, blocks }),
|
||
);
|
||
index.push({
|
||
id,
|
||
title: data.title,
|
||
order: Number.isFinite(data.order) ? data.order : 1000,
|
||
hidden: Boolean(data.hidden),
|
||
header,
|
||
url: `${CDN}/docs/${id}.json`,
|
||
});
|
||
}
|
||
|
||
if (errors.length) {
|
||
console.error('\nОшибки сборки контента:');
|
||
for (const e of errors) {
|
||
console.error(` ${e.file}${e.line ? `:${e.line}${e.column ? `:${e.column}` : ''}` : ''} — ${e.hint}`);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
|
||
index.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
||
await fs.writeFile(path.join(OUT, 'index.json'), JSON.stringify(index, null, 2));
|
||
|
||
const docsCount = index.length;
|
||
const size = (await fs.stat(path.join(OUT, 'index.json'))).size;
|
||
console.log(`Готово: ${docsCount} документ(ов), index.json ${size} байт → out/`);
|
||
}
|
||
|
||
await main();
|