sync: full site from notebook (source of truth) + missing media
This commit is contained in:
commit
5f18505943
505 files changed
+35927
No files matched your search
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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/**)");
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user