Files
Ku6epXBOCTuK b1b4d29803
publish-content / build (push) Canceled after 0s
feat: initial version
2026-09-05 20:39:54 +05:00

146 lines
4.9 KiB
JavaScript
Raw Permalink 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 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();