sync: full site from notebook (source of truth) + missing media

This commit is contained in:
root committed 2026-07-24 20:20:51 +02:00
commit 5f18505943
505 files changed
+35927

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
import fs from "node:fs";
import path from "node:path";
const rootDir = process.cwd();
const srcPagesDir = path.join(rootDir, "src", "pages");
const srcPartialsDir = path.join(rootDir, "src", "partials");
const outDir = path.join(rootDir, "public");
const includeRe = /\{\{\>\s*([a-z0-9-]+)\s*\}\}/gi;
const varRe = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
function readUtf8(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function walkHtmlFiles(dir, acc = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkHtmlFiles(abs, acc);
continue;
}
if (entry.isFile() && entry.name.endsWith(".html")) {
acc.push(abs);
}
}
return acc;
}
function loadPartials() {
const partialMap = new Map();
for (const filePath of walkHtmlFiles(srcPartialsDir)) {
const name = path.basename(filePath, ".html");
partialMap.set(name, readUtf8(filePath));
}
return partialMap;
}
function renderTemplate(input, partials, vars, stack = []) {
const withIncludes = input.replace(includeRe, (_, name) => {
const key = String(name).trim();
if (!partials.has(key)) {
throw new Error(`Partial "${key}" not found`);
}
if (stack.includes(key)) {
throw new Error(`Circular partial include: ${[...stack, key].join(" -> ")}`);
}
const partialContent = partials.get(key);
return renderTemplate(partialContent, partials, vars, [...stack, key]);
});
return withIncludes.replace(varRe, (_, name) => {
const key = String(name).trim();
if (Object.prototype.hasOwnProperty.call(vars, key)) {
return String(vars[key]);
}
return `{{${key}}}`;
});
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
/** На странице кейса убираем из HTML карусели карточку со ссылкой на текущий файл (href уже с подставленным casesPrefix). */
function stripCurrentCasePortfolioCarouselItem(html, omitHrefBasename) {
if (!omitHrefBasename) return html;
const hrefDouble = `href="${omitHrefBasename}"`;
const hrefSingle = `href='${omitHrefBasename}'`;
const itemClass = "projects-carousel__item";
let search = 0;
while (true) {
const i = html.indexOf("<article", search);
if (i === -1) return html;
const tagEnd = html.indexOf(">", i);
if (tagEnd === -1) return html;
const openTag = html.slice(i, tagEnd + 1);
if (!openTag.includes(itemClass)) {
search = i + 9;
continue;
}
const contentStart = tagEnd + 1;
const closeIdx = html.indexOf("</article>", contentStart);
if (closeIdx === -1) return html;
const afterArticle = closeIdx + "</article>".length;
const block = html.slice(i, afterArticle);
if (block.includes(hrefDouble) || block.includes(hrefSingle)) {
return html.slice(0, i) + html.slice(afterArticle);
}
search = contentStart;
}
}
function build() {
const partials = loadPartials();
const pageFiles = walkHtmlFiles(srcPagesDir);
for (const pagePath of pageFiles) {
const rel = path.relative(srcPagesDir, pagePath);
const outPath = path.join(outDir, rel);
const pageDirRel = path.dirname(rel);
const depth = pageDirRel === "." ? 0 : pageDirRel.split(path.sep).length;
const vars = {
assetPrefix: depth === 0 ? "" : "../".repeat(depth),
casesPrefix: depth === 0 ? "cases/" : "",
};
const source = readUtf8(pagePath);
let rendered = renderTemplate(source, partials, vars);
const relNorm = rel.split(path.sep).join("/");
if (relNorm.startsWith("cases/")) {
const omitFile = path.basename(pagePath);
rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile);
}
ensureDir(path.dirname(outPath));
fs.writeFileSync(outPath, rendered, "utf8");
}
console.log(`Built ${pageFiles.length} pages into public/`);
}
build();
+400
View File
@@ -0,0 +1,400 @@
/**
* Сканирует папку с .mp4, создаёт рядом .webm и .webp (превью).
* Пропускает ролик, если уже есть оба выхода с тем же базовым именем.
* Недостающие из пары — догенерирует.
*
* Требуется: ffmpeg и ffprobe в PATH.
*
* Использование: node scripts/convert-videos.mjs <папка_с_роликами>
*/
import { execFile, execFileSync, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const isTty = process.stdout.isTTY;
function hasBin(name) {
try {
execFileSync(name, ["-version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
function getDurationSec(inputPath) {
const out = execFileSync("ffprobe", [
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
inputPath,
], { encoding: "utf8" });
const n = parseFloat(String(out).trim());
return Number.isFinite(n) && n > 0 ? n : 0.1;
}
function hasAudioStream(inputPath) {
try {
const o = execFileSync(
"ffprobe",
[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=index",
"-of",
"csv=p=0",
inputPath,
],
{ encoding: "utf8" },
);
return o.trim().length > 0;
} catch {
return false;
}
}
/**
* @param {string[]} ffmpegArgs аргументы без префикса `ffmpeg` (с `-i` и в конце выход)
* @param {{ durationSec: number, onProgress: (p: number) => void }} o
*/
function runFfmpegProgress(ffmpegArgs, { durationSec, onProgress }) {
const totalMs = Math.max(1, Math.round(durationSec * 1000));
const allArgs = [
"-hide_banner",
"-y",
"-loglevel",
"error",
"-progress",
"pipe:1",
...ffmpegArgs,
];
return new Promise((resolve, reject) => {
const child = spawn("ffmpeg", allArgs, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
onProgress(0);
let outBuf = "";
let errBuf = "";
let lastPct = -1;
let lastLogTs = 0;
if (child.stdout) {
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
outBuf += chunk;
for (;;) {
const nl = outBuf.indexOf("\n");
if (nl < 0) break;
const line = outBuf.slice(0, nl);
outBuf = outBuf.slice(nl + 1);
if (line.startsWith("out_time_ms=")) {
const ms = parseInt(line.slice("out_time_ms=".length), 10);
if (!Number.isFinite(ms)) continue;
const pct = Math.min(100, (ms / totalMs) * 100);
const now = Date.now();
const tick =
isTty
? pct - lastPct >= 0.3 || now - lastLogTs > 200
: Math.floor(pct) > Math.floor(lastPct) && Math.floor(pct) % 5 === 0;
if (tick && pct > lastPct) {
lastPct = pct;
lastLogTs = now;
onProgress(pct);
}
}
}
});
}
if (child.stderr) {
child.stderr.setEncoding("utf8");
child.stderr.on("data", (d) => {
errBuf += d;
});
}
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
onProgress(100);
resolve();
} else {
reject(
new Error(
(errBuf && errBuf.trim()) || `ffmpeg завершился с кодом ${code}`,
),
);
}
});
});
}
function runFfmpegQuiet(ffmpegArgs) {
return new Promise((resolve, reject) => {
execFile("ffmpeg", ["-hide_banner", "-y", ...ffmpegArgs], (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* @param {object} p
* @param {number} p.fileIndex 1..fileTotal
* @param {number} p.fileTotal
* @param {number} p.stepIndex текущий шаг 0..totalSteps-1
* @param {number} p.totalSteps
* @param {string} p.name basename mp4
* @param {string} p.label webm|webp
* @param {number} p.stepPct 0..100 внутри шага
*/
function renderStatus(p) {
const overall =
p.totalSteps > 0
? ((p.stepIndex + p.stepPct / 100) / p.totalSteps) * 100
: 100;
const s = `Общий: ${overall.toFixed(0)}% | ролик ${p.fileIndex}/${p.fileTotal}: ${p.name} | ${p.label} ${p.stepPct.toFixed(0)}%`;
if (isTty) {
process.stdout.write(`\r\x1B[K${s}`);
} else {
const now = Date.now();
if (!p._last || now - p._last > 500 || p.stepPct >= 100) {
p._last = now;
console.log(s);
}
}
}
async function toWebm(inputPath, outPath, statusCtx) {
const a = [
"-i",
inputPath,
"-c:v",
"libvpx-vp9",
"-crf",
"32",
"-b:v",
"0",
];
if (hasAudioStream(inputPath)) {
a.push("-c:a", "libopus", "-b:a", "128k");
} else {
a.push("-an");
}
a.push(outPath);
const d = getDurationSec(inputPath);
await runFfmpegProgress(a, {
durationSec: d,
onProgress: (pct) => {
if (statusCtx) {
statusCtx.stepPct = pct;
renderStatus(statusCtx);
}
},
});
}
/** Превью: один кадр, timestamp ~ min(1 c, 10% длительности), max ширина 1280. */
async function toWebpPreview(inputPath, outPath) {
const d = getDurationSec(inputPath);
const ss = Math.min(1, Math.max(0, d * 0.1));
await runFfmpegQuiet([
"-ss",
String(ss),
"-i",
inputPath,
"-vframes",
"1",
"-vf",
"scale=1280:-1:flags=lanczos",
"-c:v",
"libwebp",
"-quality",
"85",
outPath,
]);
}
function listMp4(dir) {
const names = fs.readdirSync(dir, { withFileTypes: true });
return names
.filter((d) => d.isFile() && d.name.toLowerCase().endsWith(".mp4"))
.map((d) => path.join(dir, d.name));
}
async function main() {
const raw = process.argv[2];
if (!raw) {
console.error("Укажи папку: node scripts/convert-videos.mjs <папка>");
process.exit(1);
}
const targetDir = path.resolve(projectRoot, raw);
if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
console.error("Папка не найдена или не каталог:", targetDir);
process.exit(1);
}
if (!hasBin("ffmpeg") || !hasBin("ffprobe")) {
console.error("Нужны ffmpeg и ffprobe в PATH.");
process.exit(1);
}
const mp4s = listMp4(targetDir);
if (mp4s.length === 0) {
console.log("В папке нет .mp4:", targetDir);
return;
}
let skipped = 0;
/** @type {{ mp4: string, base: string, webm: string, webp: string, needW: boolean, needP: boolean }[]} */
const jobs = [];
for (const mp4 of mp4s) {
const base = path.join(
path.dirname(mp4),
path.basename(mp4, path.extname(mp4)),
);
const webm = base + ".webm";
const webp = base + ".webp";
const haveWebm = fs.existsSync(webm);
const haveWebp = fs.existsSync(webp);
if (haveWebm && haveWebp) {
skipped += 1;
continue;
}
jobs.push({
mp4,
base,
webm,
webp,
needW: !haveWebm,
needP: !haveWebp,
});
}
if (skipped > 0) {
console.log(
`Пропущено (уже webm+webp): ${skipped} из ${mp4s.length} роликов.`,
);
}
const totalSteps = jobs.reduce(
(n, j) => n + (j.needW ? 1 : 0) + (j.needP ? 1 : 0),
0,
);
if (jobs.length === 0) {
console.log("Конвертировать нечего.");
return;
}
console.log(
`К конвертации: ${jobs.length} роликов, шагов кодирования: ${totalSteps}.`,
);
let stepIndex = 0;
const fileTotal = jobs.length;
for (let fi = 0; fi < jobs.length; fi += 1) {
const j = jobs[fi];
const name = path.basename(j.mp4);
const fileIndex = fi + 1;
const rel = path.relative(projectRoot, j.mp4) || j.mp4;
try {
if (j.needW) {
if (!isTty) {
console.log(
`Ролик ${fileIndex}/${fileTotal}: ${name} — webm (шаг ${stepIndex + 1}/${totalSteps})`,
);
} else {
process.stdout.write(
`\nРолик ${fileIndex}/${fileTotal}: ${name} — webm\n`,
);
}
const ctx = isTty
? {
fileIndex,
fileTotal,
stepIndex,
totalSteps,
name,
label: "webm",
stepPct: 0,
}
: {
fileIndex,
fileTotal,
stepIndex,
totalSteps,
name,
label: "webm",
stepPct: 0,
_last: 0,
};
await toWebm(j.mp4, j.webm, ctx);
if (isTty) {
process.stdout.write("\n");
}
stepIndex += 1;
} else {
if (!isTty) {
console.log(
`Ролик ${fileIndex}/${fileTotal}: ${name} — webm уже есть.`,
);
}
}
if (j.needP) {
if (!isTty) {
console.log(
`Ролик ${fileIndex}/${fileTotal}: ${name} — webp (шаг ${stepIndex + 1}/${totalSteps})`,
);
} else {
if (!j.needW) {
process.stdout.write(
`\nРолик ${fileIndex}/${fileTotal}: ${name} — webp\n`,
);
}
const overall = (stepIndex / totalSteps) * 100;
process.stdout.write(
`\r\x1B[KОбщий: ${overall.toFixed(0)}% | ролик ${fileIndex}/${fileTotal}: ${name} | webp …`,
);
}
await toWebpPreview(j.mp4, j.webp);
if (isTty) {
const o = ((stepIndex + 1) / totalSteps) * 100;
process.stdout.write(
`\r\x1B[KОбщий: ${o.toFixed(0)}% | ролик ${fileIndex}/${fileTotal}: ${name} | webp 100%`,
);
process.stdout.write("\n");
} else {
const o = ((stepIndex + 1) / totalSteps) * 100;
console.log(`Общий: ${o.toFixed(0)}% | webp готово — ${rel}`);
}
stepIndex += 1;
} else if (j.needW && !j.needP && !isTty) {
console.log(
`Ролик ${fileIndex}/${fileTotal}: ${name} — webp уже есть.`,
);
}
} catch (e) {
if (isTty) {
process.stdout.write("\n");
}
console.error("Ошибка для", rel, e.message || e);
process.exitCode = 1;
}
}
if (isTty) {
process.stdout.write(
`\r\x1B[KГотово: ${jobs.length} роликов, ${totalSteps} шагов.\n`,
);
} else {
console.log(`Готово: ${jobs.length} роликов, ${totalSteps} шагов.`);
}
}
main();
+151
View File
@@ -0,0 +1,151 @@
/**
* Копирует Plyr и шрифты из node_modules в public/ (автономный деплой без CDN).
* Plyr: 3.8.4 (package.json)
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..");
const nodeModules = path.join(root, "node_modules");
const publicDir = path.join(root, "public");
const plyrOut = path.join(publicDir, "vendor", "plyr");
const fontsOut = path.join(publicDir, "assets", "fonts");
const fontsFilesOut = path.join(fontsOut, "files");
const PLYR_VERSION = "3.8.4";
/** @type {{ id: string, sources: { pkg: string, css: string, familyReplace?: [string, string] }[] }[]} */
const FONT_BUNDLES = [
{
id: "merriweather",
sources: [
{ pkg: "@fontsource/alice", css: "400.css" },
{ pkg: "@fontsource/merriweather", css: "400.css" },
{ pkg: "@fontsource/merriweather", css: "700.css" },
],
},
{
id: "geologica",
sources: [
{
pkg: "@fontsource-variable/geologica",
css: "wght.css",
familyReplace: ["Geologica Variable", "Geologica"],
},
{ pkg: "@fontsource/pt-sans", css: "400.css" },
{ pkg: "@fontsource/pt-sans", css: "700.css" },
],
},
{
id: "mono",
sources: [
{ pkg: "@fontsource/pt-mono", css: "400.css" },
{ pkg: "@fontsource/source-code-pro", css: "400.css" },
{ pkg: "@fontsource/source-code-pro", css: "500.css" },
{ pkg: "@fontsource/source-code-pro", css: "600.css" },
{ pkg: "@fontsource/source-code-pro", css: "700.css" },
],
},
];
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function emptyDir(dir) {
if (!fs.existsSync(dir)) return;
for (const name of fs.readdirSync(dir)) {
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
}
}
function copyPlyr() {
const dist = path.join(nodeModules, "plyr", "dist");
for (const name of ["plyr.css", "plyr.min.mjs"]) {
const src = path.join(dist, name);
if (!fs.existsSync(src)) {
throw new Error(`Plyr file not found: ${src}. Run npm install.`);
}
}
emptyDir(plyrOut);
ensureDir(plyrOut);
fs.copyFileSync(path.join(dist, "plyr.css"), path.join(plyrOut, "plyr.css"));
fs.copyFileSync(path.join(dist, "plyr.min.mjs"), path.join(plyrOut, "plyr.min.mjs"));
console.log(`Plyr ${PLYR_VERSION} -> public/vendor/plyr/`);
}
const woff2Re = /url\(\.\/files\/([^)]+\.woff2)\)/g;
function transformFontCss(raw, familyReplace) {
let out = raw;
if (familyReplace) {
const [from, to] = familyReplace;
out = out.split(`'${from}'`).join(`'${to}'`);
out = out.split(`"${from}"`).join(`"${to}"`);
}
out = out.replace(/,\s*url\(\.\/files\/[^)]+\.woff\)/g, "");
out = out.replace(/\s+format\(['"]woff['"]\)/g, "");
return out;
}
function collectWoff2Files(cssText) {
const names = new Set();
let m;
woff2Re.lastIndex = 0;
while ((m = woff2Re.exec(cssText)) !== null) {
names.add(m[1]);
}
return names;
}
function buildFontBundles() {
emptyDir(fontsFilesOut);
ensureDir(fontsFilesOut);
const copied = new Set();
for (const bundle of FONT_BUNDLES) {
const blocks = [];
for (const source of bundle.sources) {
const pkgDir = path.join(nodeModules, source.pkg);
const cssPath = path.join(pkgDir, source.css);
if (!fs.existsSync(cssPath)) {
throw new Error(`Font CSS not found: ${cssPath}`);
}
const raw = fs.readFileSync(cssPath, "utf8");
const transformed = transformFontCss(raw, source.familyReplace);
blocks.push(transformed.trim());
const filesDir = path.join(pkgDir, "files");
for (const fileName of collectWoff2Files(transformed)) {
const srcFile = path.join(filesDir, fileName);
const dstFile = path.join(fontsFilesOut, fileName);
if (!fs.existsSync(srcFile)) {
throw new Error(`Font file not found: ${srcFile}`);
}
if (!copied.has(fileName)) {
fs.copyFileSync(srcFile, dstFile);
copied.add(fileName);
}
}
}
const outCss = `${blocks.join("\n\n")}\n`;
const outPath = path.join(fontsOut, `${bundle.id}.css`);
fs.writeFileSync(outPath, outCss, "utf8");
console.log(`${bundle.id}.css (${copied.size} woff2 total so far) -> public/assets/fonts/`);
}
console.log(`Font files: ${copied.size} woff2 in public/assets/fonts/files/`);
}
function main() {
copyPlyr();
buildFontBundles();
console.log("vendor:copy done.");
}
main();
+179
View File
@@ -0,0 +1,179 @@
/**
* Инкрементальный деплой public/ на VPS в Docker volume nginx.
* Загружает только изменённые файлы через tar-пайп над SSH (одно соединение).
*
* Использование:
* node scripts/deploy-vps.mjs [sourceDir]
*/
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import { execFile, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const SSH_KEY = path.join(os.homedir(), ".ssh", "beget");
const SSH_RAW_OPTS = [
"-i", SSH_KEY,
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=15",
"-o", "ServerAliveInterval=30",
];
const SSH_HOST = "root@155.212.186.39";
const TARGET_DIR = "/var/lib/docker/volumes/s9mrcps8xw02r1dak0fsznix-www-data/_data";
const MANIFEST_PATH = path.join(projectRoot, ".deploy-manifest.json");
const SOURCE_DIR = (() => {
const raw = process.argv[2] ?? "public";
return path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw);
})();
function ssh(args) {
return new Promise((resolve, reject) => {
execFile("ssh", [...SSH_RAW_OPTS, SSH_HOST, ...args], { maxBuffer: 1024 * 1024 }, (err, stdout) => {
if (err) reject(new Error((err.stderr || "").trim() || err.message));
else resolve((stdout || "").trim());
});
});
}
function scanFiles(dir) {
const entries = [];
const walk = (current, relative) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const rel = relative ? `${relative}/${entry.name}` : entry.name;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(full, rel);
} else if (entry.isFile()) {
const stat = fs.statSync(full);
entries.push({
relPath: rel.replace(/\\/g, "/"),
mtime: stat.mtimeMs,
size: stat.size,
});
}
}
};
walk(dir, "");
return entries;
}
function loadManifest() {
try {
return JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8"));
} catch {
return {};
}
}
function saveManifest(manifest) {
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf-8");
}
/** Загрузить изменённые файлы одним tar-пайпом через SSH */
function uploadViaTar(filePaths, sourceDir) {
return new Promise((resolve, reject) => {
const listPath = path.join(os.tmpdir(), `deploy-files-${Date.now()}.txt`);
fs.writeFileSync(listPath, filePaths.join("\n"), "utf-8");
// cwd вместо -C — обходит баг bsdtar с кириллицей в пути
const tar = spawn("tar", [
"-c",
`--files-from=${listPath}`,
"--format=ustar",
], { cwd: sourceDir });
const sshProc = spawn("ssh", [
...SSH_RAW_OPTS,
SSH_HOST,
`tar xf - -C ${TARGET_DIR}`,
]);
tar.stdout.pipe(sshProc.stdin);
let stderrChunks = [];
sshProc.stderr.on("data", (d) => { stderrChunks.push(d); });
tar.stderr.on("data", (d) => { stderrChunks.push(d); });
function cleanup() {
try { fs.unlinkSync(listPath); } catch { /* ok */ }
}
sshProc.on("close", (code) => {
cleanup();
if (code !== 0) {
const msg = Buffer.concat(stderrChunks).toString().trim();
reject(new Error(msg || `ssh exited with code ${code}`));
} else {
resolve();
}
});
sshProc.on("error", (err) => { cleanup(); reject(err); });
tar.on("error", (err) => { cleanup(); sshProc.kill(); reject(err); });
});
}
async function deploy() {
// 1. Убедиться, что целевая директория существует
console.log("Проверка соединения...");
await ssh([`mkdir -p ${TARGET_DIR}`]);
// 2. Сравнить локальные файлы с манифестом
console.log("Сканирование...");
const localFiles = scanFiles(SOURCE_DIR);
const manifest = loadManifest();
const changedPaths = [];
for (const f of localFiles) {
const prev = manifest[f.relPath];
if (!prev || prev.mtime !== f.mtime || prev.size !== f.size) {
changedPaths.push(f.relPath);
}
}
const localPaths = new Set(localFiles.map((f) => f.relPath));
const removedPaths = Object.keys(manifest).filter((p) => !localPaths.has(p));
if (changedPaths.length === 0 && removedPaths.length === 0) {
console.log("Изменений нет — деплой не требуется.");
return;
}
console.log(`Изменено/новых: ${changedPaths.length}, удалено: ${removedPaths.length}`);
// 3. Загрузить изменённые файлы одним tar-пайпом
if (changedPaths.length > 0) {
console.log("Загрузка файлов...");
await uploadViaTar(changedPaths, SOURCE_DIR);
console.log("Загрузка завершена.");
}
// 4. Удалить файлы, которых больше нет локально
if (removedPaths.length > 0) {
console.log("Удаление лишних файлов...");
const rmList = removedPaths.map((p) => `${TARGET_DIR}/${p}`).join(" ");
await ssh([`rm -f ${rmList}`]);
await ssh([`find ${TARGET_DIR} -type d -empty -delete 2>/dev/null || true`]);
}
// 5. Обновить манифест
saveManifest(
Object.fromEntries(localFiles.map((f) => [f.relPath, { mtime: f.mtime, size: f.size }])),
);
}
const relDir = path.relative(projectRoot, SOURCE_DIR);
deploy()
.then(() => {
console.log(`Готово: ${relDir}/ → ${SSH_HOST}:${TARGET_DIR}`);
})
.catch((err) => {
console.error("Ошибка деплоя:", err.message || err);
process.exit(1);
});
+135
View File
@@ -0,0 +1,135 @@
/**
* Рекурсивно обрабатывает .png, .jpg, .jpeg в указанной папке, создаёт рядом
* `то же имя.webp` с тем же размером. Качество WebP высокое (90), без смены разрешения.
*
* Пропуск: если рядом уже есть .webp с временем изменения ≥ исходника — пропуск
* (кроме --force). Для догенерации после правки картинки — удали webp или дай --force.
*
* Использование: node scripts/optimize-images.mjs <папка> [--force]
*/
import path from "node:path";
import fsp from "node:fs/promises";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg"]);
/** @type {import('sharp').WebpOptions} */
const WEBP = {
quality: 90,
alphaQuality: 100,
effort: 4,
};
/**
* @param {string} dir
* @returns {AsyncGenerator<string, void, void>}
*/
async function* walkFiles(dir) {
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch (e) {
console.error("Не открылась папка:", dir, e);
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) yield* walkFiles(full);
else if (e.isFile()) yield full;
}
}
/**
* @param {string} p
*/
async function mtime(p) {
const s = await fsp.stat(p);
return s.mtimeMs;
}
/**
* @param {string} inputPath
* @param {string} outPath
* @param {boolean} force
*/
async function shouldSkip(inputPath, outPath, force) {
if (force) return false;
try {
const [tin, tout] = await Promise.all([mtime(inputPath), mtime(outPath)]);
return tout >= tin;
} catch {
return false;
}
}
/**
* @param {string} inputPath
* @param {string} outPath
*/
async function toWebp(inputPath, outPath) {
await sharp(inputPath).webp(WEBP).toFile(outPath);
}
function parseArgs(argv) {
const rest = argv.slice(2);
const force = rest.includes("--force");
const folder = rest.filter((a) => a !== "--force")[0];
return { folder, force };
}
const { folder, force } = parseArgs(process.argv);
if (!folder) {
console.error(
"Укажи папку с картинками.\n" +
" node scripts/optimize-images.mjs <папка> [--force]\n" +
" npm run optimize-images -- <папка> [--force]",
);
process.exit(1);
}
const root = path.isAbsolute(folder) ? folder : path.resolve(projectRoot, folder);
try {
const st = await fsp.stat(root);
if (!st.isDirectory()) {
console.error("Не папка:", root);
process.exit(1);
}
} catch {
console.error("Папка не найдена:", root);
process.exit(1);
}
let done = 0;
let skipped = 0;
let err = 0;
for await (const filePath of walkFiles(root)) {
const ext = path.extname(filePath).toLowerCase();
if (!IMAGE_EXT.has(ext)) continue;
const outPath = filePath.replace(/\.(png|jpe?g)$/i, ".webp");
if (outPath === filePath) continue;
try {
if (await shouldSkip(filePath, outPath, force)) {
skipped += 1;
console.log("пропуск (уже свежее):", path.relative(projectRoot, outPath));
continue;
}
await toWebp(filePath, outPath);
done += 1;
console.log("ok:", path.relative(projectRoot, outPath));
} catch (e) {
err += 1;
console.error("ошибка:", filePath, e);
}
}
console.log(`\nготово: сгенерировано ${done}, пропущено ${skipped}, ошибок ${err}`);
if (err > 0) process.exit(1);
+214
View File
@@ -0,0 +1,214 @@
/**
* Локальный сервер: public/ + BrowserSync + API правок контента (только localhost).
*/
import browserSync from "browser-sync";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, "..");
const publicDir = path.join(root, "public");
const editsDir = path.join(root, "texts", "content-edits");
const PENDING_FILE = path.join(editsDir, "pending.json");
const PAGE_RE = /^[\w./-]+\.html$/;
function ensureEditsDir() {
fs.mkdirSync(editsDir, { recursive: true });
}
function isLocalRequest(req) {
const host = (req.headers.host || "").split(":")[0];
return host === "localhost" || host === "127.0.0.1";
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function sendJson(res, status, data) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(data));
}
function emptyPending() {
return { version: 2, updatedAt: null, items: [] };
}
function readPending() {
if (!fs.existsSync(PENDING_FILE)) return emptyPending();
try {
const data = JSON.parse(fs.readFileSync(PENDING_FILE, "utf8"));
if (!Array.isArray(data.items)) data.items = [];
data.version = 2;
return data;
} catch {
return emptyPending();
}
}
function writePending(data) {
data.version = 2;
data.updatedAt = new Date().toISOString();
data.items = data.items.map((item, index) => ({
...item,
n: index + 1,
}));
fs.writeFileSync(PENDING_FILE, JSON.stringify(data, null, 2) + "\n", "utf8");
return data;
}
function legacyFileToPage(filename) {
const base = filename.replace(/\.json$/i, "");
return `${base.replace(/--/g, "/")}.html`;
}
function migrateLegacyFiles() {
if (!fs.existsSync(editsDir)) return;
const pending = readPending();
let changed = false;
for (const name of fs.readdirSync(editsDir)) {
if (name === "pending.json" || name === "README.md" || name === ".gitkeep") continue;
if (!name.endsWith(".json")) continue;
const legacyPath = path.join(editsDir, name);
let legacy;
try {
legacy = JSON.parse(fs.readFileSync(legacyPath, "utf8"));
} catch {
continue;
}
const page = legacy.page || legacyFileToPage(name);
const legacyItems = Array.isArray(legacy.items) ? legacy.items : [];
for (const item of legacyItems) {
if (!(item.instruction || "").trim()) continue;
pending.items.push({ ...item, page });
changed = true;
}
fs.unlinkSync(legacyPath);
changed = true;
}
if (changed) writePending(pending);
}
function itemsForPage(pending, page) {
return pending.items
.filter((item) => item.page === page && (item.instruction || "").trim())
.map((item, index) => ({ ...item, n: index + 1 }));
}
function mergePageItems(page, pageItems) {
const pending = readPending();
const trimmed = pageItems
.filter((item) => (item.instruction || "").trim())
.map((item) => ({ ...item, page }));
const others = pending.items.filter((item) => item.page !== page);
pending.items = [...others, ...trimmed];
return writePending(pending);
}
function contentEditsApi(req, res, next) {
const url = new URL(req.url || "/", "http://localhost");
if (!url.pathname.startsWith("/api/content-edits")) {
next();
return;
}
if (!isLocalRequest(req)) {
sendJson(res, 403, { error: "Forbidden" });
return;
}
ensureEditsDir();
migrateLegacyFiles();
const page = url.searchParams.get("page") || "";
if (req.method === "GET") {
const pending = readPending();
if (!page) {
sendJson(res, 200, pending);
return;
}
if (!PAGE_RE.test(page)) {
sendJson(res, 400, { error: "Invalid page" });
return;
}
sendJson(res, 200, {
version: 2,
page,
updatedAt: pending.updatedAt,
items: itemsForPage(pending, page),
});
return;
}
if (req.method === "PUT") {
readBody(req)
.then((body) => {
let payload;
try {
payload = JSON.parse(body);
} catch {
sendJson(res, 400, { error: "Invalid JSON" });
return;
}
const payloadPage = payload.page || page;
if (!PAGE_RE.test(payloadPage)) {
sendJson(res, 400, { error: "Invalid page" });
return;
}
if (page && payloadPage !== page) {
sendJson(res, 400, { error: "Page mismatch" });
return;
}
if (!Array.isArray(payload.items)) {
sendJson(res, 400, { error: "items must be array" });
return;
}
const pending = mergePageItems(payloadPage, payload.items);
sendJson(res, 200, {
version: 2,
page: payloadPage,
updatedAt: pending.updatedAt,
items: itemsForPage(pending, payloadPage),
});
})
.catch(() => sendJson(res, 500, { error: "Write failed" }));
return;
}
sendJson(res, 405, { error: "Method not allowed" });
}
ensureEditsDir();
if (!fs.existsSync(PENDING_FILE)) {
writePending(emptyPending());
}
migrateLegacyFiles();
browserSync.create().init({
server: {
baseDir: publicDir,
middleware: [contentEditsApi],
},
files: [path.join(publicDir, "**", "*")],
port: 4175,
ui: { port: 4176 },
notify: false,
open: false,
ghostMode: false,
});
console.log("serve-dev: http://localhost:4175 (public/, reload only public/**)");
+162
View File
@@ -0,0 +1,162 @@
/**
* Копирует содержимое папки public в папку gitverse.
* Файлы `.png` не копируются (на GitVerse достаточно WebP и прочих ассетов).
*
* По умолчанию:
* node scripts/sync-gitverse.mjs
* (копирует projectRoot/public -> projectRoot/gitverse)
*
* Можно передать свои пути:
* node scripts/sync-gitverse.mjs <sourceDir> <targetDir>
*/
import path from "node:path";
import os from "node:os";
import fsp from "node:fs/promises";
import { execFile, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const PRESERVE_IN_TARGET = new Set([".git", ".gitverse"]);
function shouldSkipGitverseFile(fileName) {
return path.extname(fileName).toLowerCase() === ".png";
}
const DEFAULT_SSH_KEY_PATH = path.join(os.homedir(), ".ssh", "id_ed25519_work");
const GITVERSE_SITE_URL = "https://mamadaevv.gitverse.site/me/";
/** Системный браузер по умолчанию (Windows / macOS / Linux). */
function openUrlInDefaultBrowser(url) {
if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return;
const opts = { detached: true, stdio: "ignore" };
let child;
if (process.platform === "win32") {
child = spawn("cmd", ["/c", "start", "", url], opts);
} else if (process.platform === "darwin") {
child = spawn("open", [url], opts);
} else {
child = spawn("xdg-open", [url], opts);
}
child?.unref();
}
function resolveDir(input, fallback) {
const raw = input ?? fallback;
return path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw);
}
async function ensureDirExists(dirPath, label) {
try {
const st = await fsp.stat(dirPath);
if (!st.isDirectory()) {
throw new Error(`${label} не является папкой: ${dirPath}`);
}
} catch (error) {
if (error && error.code === "ENOENT") {
throw new Error(`${label} не найдена: ${dirPath}`);
}
throw error;
}
}
async function cleanTargetDir(targetDir) {
await fsp.mkdir(targetDir, { recursive: true });
const entries = await fsp.readdir(targetDir, { withFileTypes: true });
for (const entry of entries) {
if (PRESERVE_IN_TARGET.has(entry.name)) continue;
const fullPath = path.join(targetDir, entry.name);
await fsp.rm(fullPath, { recursive: true, force: true });
}
}
async function copyDirFiltered(sourceDir, targetDir) {
await fsp.mkdir(targetDir, { recursive: true });
const entries = await fsp.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(sourceDir, entry.name);
const dstPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
await copyDirFiltered(srcPath, dstPath);
continue;
}
if (!entry.isFile() || shouldSkipGitverseFile(entry.name)) continue;
await fsp.copyFile(srcPath, dstPath);
}
}
function runGit(args, cwd) {
return new Promise((resolve, reject) => {
const gitEnv = {
...process.env,
GIT_SSH_COMMAND: `ssh -i "${DEFAULT_SSH_KEY_PATH}" -o IdentitiesOnly=yes`,
};
execFile("git", args, { cwd, encoding: "utf8", env: gitEnv }, (error, stdout, stderr) => {
if (error) {
reject(new Error((stderr || error.message || "").trim()));
return;
}
resolve((stdout || "").trim());
});
});
}
function makeCommitMessage() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
return `sync public ${y}-${m}-${d} ${hh}:${mm}`;
}
async function commitAndPush(targetDir) {
const gitDir = path.join(targetDir, ".git");
await ensureDirExists(gitDir, "Git репозиторий (.git)");
try {
await fsp.access(DEFAULT_SSH_KEY_PATH);
} catch {
throw new Error(`SSH-ключ не найден: ${DEFAULT_SSH_KEY_PATH}`);
}
await runGit(["add", "."], targetDir);
const changed = await runGit(["status", "--porcelain"], targetDir);
if (!changed) {
console.log("Изменений для коммита нет.");
return;
}
const message = makeCommitMessage();
await runGit(["commit", "-m", message], targetDir);
await runGit(["push"], targetDir);
console.log(`Коммит и пуш выполнены: ${message}`);
openUrlInDefaultBrowser(GITVERSE_SITE_URL);
console.log(`Открыт в браузере: ${GITVERSE_SITE_URL}`);
}
async function main() {
const sourceDir = resolveDir(process.argv[2], "public");
const targetDir = resolveDir(process.argv[3], "gitverse");
await ensureDirExists(sourceDir, "Исходная папка");
if (sourceDir === targetDir) {
throw new Error("Исходная и целевая папки совпадают.");
}
await cleanTargetDir(targetDir);
await copyDirFiltered(sourceDir, targetDir);
console.log(
`Синхронизировано: ${path.relative(projectRoot, sourceDir)} -> ${path.relative(projectRoot, targetDir)} (сохранены .git и .gitverse)`,
);
await commitAndPush(targetDir);
}
main().catch((error) => {
console.error("Ошибка копирования:", error.message || error);
process.exit(1);
});