@@ -0,0 +1,145 @@
|
||||
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();
|
||||
@@ -0,0 +1,18 @@
|
||||
import sharp from 'sharp';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function hashFile(p) {
|
||||
const buf = await fs.readFile(p);
|
||||
return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
export async function optimizeImage(src, dest, { width = 640, height = null } = {}) {
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
let img = sharp(src);
|
||||
img = height
|
||||
? img.resize(width, height, { fit: 'cover' })
|
||||
: img.resize({ width, withoutEnlargement: true });
|
||||
await img.webp({ quality: 80 }).toFile(dest);
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const parser = unified().use(remarkParse).use(remarkGfm);
|
||||
|
||||
export function parseDoc(md) {
|
||||
return parser.parse(md);
|
||||
}
|
||||
|
||||
// Белый список узлов mdast — всё прочее = ошибка сборки.
|
||||
const ALLOWED = new Set([
|
||||
'root',
|
||||
'heading',
|
||||
'paragraph',
|
||||
'text',
|
||||
'emphasis',
|
||||
'strong',
|
||||
'delete',
|
||||
'inlineCode',
|
||||
'break',
|
||||
'link',
|
||||
'image',
|
||||
'list',
|
||||
'listItem',
|
||||
'blockquote',
|
||||
'thematicBreak',
|
||||
]);
|
||||
|
||||
const HINTS = {
|
||||
html: 'сырой HTML в markdown запрещён',
|
||||
code: 'блоки кода запрещены (подсветка не поддерживается)',
|
||||
table: 'таблицы не поддерживаются',
|
||||
footnoteDefinition: 'сноски не поддерживаются',
|
||||
linkReference: 'используйте обычные ссылки [текст](https://…)',
|
||||
imageReference: 'используйте обычные картинки ',
|
||||
definition: 'ссылки-определения не поддерживаются',
|
||||
yaml: 'yaml-блок внутри документа запрещён',
|
||||
};
|
||||
|
||||
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
|
||||
export function validateDoc(root, fileName) {
|
||||
const errors = [];
|
||||
const walk = (node) => {
|
||||
for (const child of node.children ?? []) {
|
||||
const pos = child.position?.start ?? {};
|
||||
if (!ALLOWED.has(child.type)) {
|
||||
errors.push({
|
||||
file: fileName,
|
||||
line: pos.line ?? 0,
|
||||
column: pos.column ?? 0,
|
||||
type: child.type,
|
||||
hint: HINTS[child.type] ?? 'не поддерживается белым списком',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (child.type === 'link' && !/^https?:\/\//i.test(child.url)) {
|
||||
errors.push({
|
||||
file: fileName,
|
||||
line: pos.line ?? 0,
|
||||
column: pos.column ?? 0,
|
||||
type: 'link',
|
||||
hint: `ссылка должна быть http(s): ${child.url}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
child.type === 'image' &&
|
||||
SCHEME_RE.test(child.url) &&
|
||||
!/^https?:\/\//i.test(child.url)
|
||||
) {
|
||||
errors.push({
|
||||
file: fileName,
|
||||
line: pos.line ?? 0,
|
||||
column: pos.column ?? 0,
|
||||
type: 'image',
|
||||
hint: `картинка должна быть http(s) или относительным путём: ${child.url}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// --- Сериализация в мини-AST для DocRenderer.svelte ---
|
||||
// Блоки: ["p", ...inline] | ["h1".."h6", ...inline] | ["ul"|"ol", [items]]
|
||||
// | ["blockquote", ...blocks] | ["hr"]
|
||||
// Инлайн: строка | ["em"|"strong"|"del", ...inline] | ["code", text]
|
||||
// | ["br"] | ["a", href, ...inline] | ["img", url, alt]
|
||||
|
||||
const norm = (s) => s.replace(/\s+/g, ' ');
|
||||
|
||||
export async function serializeDoc(root, ctx) {
|
||||
const blocks = [];
|
||||
for (const child of root.children) {
|
||||
blocks.push(...(await serBlock(child, ctx)));
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
async function serBlock(node, ctx) {
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
return [['h' + Math.min(node.depth, 6), ...(await inline(node.children, ctx))]];
|
||||
case 'paragraph': {
|
||||
if (node.children.length === 1 && node.children[0].type === 'image') {
|
||||
const img = await serImage(node.children[0], ctx);
|
||||
return img ? [img] : [];
|
||||
}
|
||||
return [['p', ...(await inline(node.children, ctx))]];
|
||||
}
|
||||
case 'list': {
|
||||
const items = [];
|
||||
for (const item of node.children) items.push(await serItem(item, ctx));
|
||||
return [[node.ordered ? 'ol' : 'ul', items]];
|
||||
}
|
||||
case 'blockquote': {
|
||||
const blocks = [];
|
||||
for (const child of node.children) blocks.push(...(await serBlock(child, ctx)));
|
||||
return [['blockquote', ...blocks]];
|
||||
}
|
||||
case 'thematicBreak':
|
||||
return [['hr']];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function serItem(item, ctx) {
|
||||
const prefix = item.checked === true ? '☑ ' : item.checked === false ? '☐ ' : '';
|
||||
const single =
|
||||
item.children.length === 1 && item.children[0].type === 'paragraph';
|
||||
const inner = single
|
||||
? await inline(item.children[0].children, ctx)
|
||||
: (await Promise.all(item.children.map((c) => serBlock(c, ctx)))).flat();
|
||||
return prefix ? ['li', prefix, ...inner] : ['li', ...inner];
|
||||
}
|
||||
|
||||
async function inline(nodes, ctx) {
|
||||
const out = [];
|
||||
for (const node of nodes) {
|
||||
const serialized = await serInline(node, ctx);
|
||||
if (serialized !== null && serialized !== '') out.push(serialized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function serInline(node, ctx) {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return norm(node.value);
|
||||
case 'emphasis':
|
||||
return ['em', ...(await inline(node.children, ctx))];
|
||||
case 'strong':
|
||||
return ['strong', ...(await inline(node.children, ctx))];
|
||||
case 'delete':
|
||||
return ['del', ...(await inline(node.children, ctx))];
|
||||
case 'inlineCode':
|
||||
return ['code', node.value];
|
||||
case 'break':
|
||||
return ['br'];
|
||||
case 'link':
|
||||
return ['a', node.url, ...(await inline(node.children, ctx))];
|
||||
case 'image':
|
||||
return serImage(node, ctx);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function serImage(node, ctx) {
|
||||
const url = await ctx.resolveImage(node.url, node.position?.start);
|
||||
return url ? ['img', url, node.alt ?? ''] : null;
|
||||
}
|
||||
Reference in New Issue
Block a user