Add static home block renderer and local admin

This commit is contained in:
dcconstructions
2026-06-25 21:36:08 +03:00
parent 03e19601f3
commit aaae2da433
18 changed files with 1313 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
import { createServer } from "node:http";
import { readFile, stat, writeFile } from "node:fs/promises";
import { extname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { dirname } from "node:path";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const port = Number(process.env.PORT || 8090);
const host = process.env.HOST || "127.0.0.1";
const pagePath = join(repoRoot, "content", "pages", "home.json");
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".avif": "image/avif",
".webp": "image/webp",
".mp4": "video/mp4",
".glb": "model/gltf-binary",
};
function send(res, status, body, contentType = "text/plain; charset=utf-8") {
res.writeHead(status, {
"content-type": contentType,
"cache-control": "no-store",
});
res.end(body);
}
function sendJson(res, status, payload) {
send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8");
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
function runNodeScript(scriptName) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [join(repoRoot, "tools", scriptName)], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(stderr || stdout || `${scriptName} exited with ${code}`));
}
});
});
}
function safePublicPath(urlPath) {
const withoutQuery = decodeURIComponent(urlPath.split("?")[0]);
const requestPath =
withoutQuery === "/"
? "/index.html"
: withoutQuery === "/admin" || withoutQuery === "/admin/"
? "/admin/index.html"
: withoutQuery;
const normalized = normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
const absolute = join(repoRoot, normalized);
if (!absolute.startsWith(repoRoot)) {
return null;
}
return absolute;
}
async function serveStatic(req, res) {
const filePath = safePublicPath(new URL(req.url, `http://${host}:${port}`).pathname);
if (!filePath) {
send(res, 403, "Forbidden");
return;
}
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
send(res, 404, "Not found");
return;
}
const ext = extname(filePath);
send(res, 200, await readFile(filePath), MIME[ext] || "application/octet-stream");
} catch {
send(res, 404, "Not found");
}
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${host}:${port}`);
if (req.method === "GET" && url.pathname === "/api/page/home") {
send(res, 200, await readFile(pagePath, "utf8"), "application/json; charset=utf-8");
return;
}
if (req.method === "PUT" && url.pathname === "/api/page/home") {
const body = await readBody(req);
const parsed = JSON.parse(body);
await writeFile(pagePath, `${JSON.stringify(parsed, null, 2)}\n`);
sendJson(res, 200, { ok: true });
return;
}
if (req.method === "POST" && url.pathname === "/api/render/home") {
const result = await runNodeScript("render-home.mjs");
sendJson(res, 200, { ok: true, ...result });
return;
}
if (req.method === "POST" && url.pathname === "/api/extract/home") {
const result = await runNodeScript("extract-home-blocks.mjs");
sendJson(res, 200, { ok: true, ...result });
return;
}
await serveStatic(req, res);
} catch (error) {
sendJson(res, 500, { ok: false, error: error.message });
}
});
server.listen(port, host, () => {
console.log(`NODE.DC admin: http://${host}:${port}/admin/`);
console.log(`NODE.DC site: http://${host}:${port}/`);
});
+155
View File
@@ -0,0 +1,155 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = join(repoRoot, "index.html");
const templateRoot = join(repoRoot, "templates", "pages", "home");
const blockRoot = join(templateRoot, "blocks");
const contentPath = join(repoRoot, "content", "pages", "home.json");
const html = await readFile(sourcePath, "utf8");
const marker = {
hero: '<header id="hero" class="s h-screen">',
features: '<section id="features" class="s h-screen py mobile-flip">',
cViz: '<div class="c-viz">',
pricingIntro: '<section id="waitlist" class="s">',
waitlistFiles: '<section id="waitlist" class="s h-screen">',
demoVideoFolder: '<div data-module="folder-wrap" class="folder-w video-w">',
footer: '<footer class="s footer">',
};
function findRequired(needle, from = 0) {
const index = html.indexOf(needle, from);
if (index === -1) {
throw new Error(`Could not find marker: ${needle}`);
}
return index;
}
const heroStart = findRequired(marker.hero);
const featuresStart = findRequired(marker.features, heroStart);
const cVizStart = findRequired(marker.cViz, featuresStart);
const cVizOpenEnd = cVizStart + marker.cViz.length;
const pricingIntroStart = findRequired(marker.pricingIntro, cVizOpenEnd);
const waitlistFilesStart = findRequired(marker.waitlistFiles, pricingIntroStart);
const demoVideoFolderStart = findRequired(marker.demoVideoFolder, waitlistFilesStart);
const footerStart = findRequired(marker.footer, demoVideoFolderStart);
const footerEnd = findRequired("</footer>", footerStart) + "</footer>".length;
const shell = [
html.slice(0, heroStart),
"{{NODEDC_BLOCKS_BEFORE_CVIZ}}",
html.slice(cVizStart, cVizOpenEnd),
"{{NODEDC_BLOCKS_CVIZ}}",
html.slice(footerEnd),
].join("");
const blocks = [
{
region: "beforeCViz",
id: "hero-waitlist",
type: "staticHtml",
template: "hero-waitlist.html",
adminLabel: "Первый экран и заявка",
anchor: "hero",
html: html.slice(heroStart, featuresStart),
},
{
region: "beforeCViz",
id: "feature-video",
type: "staticHtml",
template: "feature-video.html",
adminLabel: "Демо и базовая логика",
anchor: "features",
html: html.slice(featuresStart, cVizStart),
},
{
region: "cViz",
id: "product-system",
type: "staticHtml",
template: "product-system.html",
adminLabel: "Компоненты, режимы, FAQ и тарифная таблица",
anchor: null,
html: html.slice(cVizOpenEnd, pricingIntroStart),
},
{
region: "cViz",
id: "pricing-intro",
type: "staticHtml",
template: "pricing-intro.html",
adminLabel: "Интро тарифов",
anchor: "waitlist",
html: html.slice(pricingIntroStart, waitlistFilesStart),
},
{
region: "cViz",
id: "waitlist-files",
type: "staticHtml",
template: "waitlist-files.html",
adminLabel: "Форма ожидания и txt-файлы",
anchor: "waitlist",
html: html.slice(waitlistFilesStart, demoVideoFolderStart),
},
{
region: "cViz",
id: "demo-video-folder",
type: "staticHtml",
template: "demo-video-folder.html",
adminLabel: "Иконка демо-видео в доке",
anchor: null,
html: html.slice(demoVideoFolderStart, footerStart),
},
{
region: "cViz",
id: "footer",
type: "staticHtml",
template: "footer.html",
adminLabel: "Футер",
anchor: null,
html: html.slice(footerStart, footerEnd),
},
];
const page = {
schemaVersion: 1,
slug: "home",
output: "index.html",
title: "NODE.DC",
seo: {
title: "NODE.DC — ИИ-платформа для Webflow",
description:
"Единая платформа для кастомного кода, MCP и ИИ-агентов в Webflow: контекст проекта, автоматизация, деплой и управляемая разработка.",
},
regions: [
{
id: "beforeCViz",
label: "До c-viz wrapper",
description: "Hero and first product section. These blocks live before the visual wrapper.",
blocks: blocks
.filter((block) => block.region === "beforeCViz")
.map(({ html: _html, ...block }) => ({ ...block, enabled: true })),
},
{
id: "cViz",
label: "Inside c-viz wrapper",
description: "Sections coupled to WebGL/color-flip shell. Keep these in this region for now.",
blocks: blocks
.filter((block) => block.region === "cViz")
.map(({ html: _html, ...block }) => ({ ...block, enabled: true })),
},
],
};
await mkdir(blockRoot, { recursive: true });
await mkdir(dirname(contentPath), { recursive: true });
await writeFile(join(templateRoot, "shell.html"), shell);
for (const block of blocks) {
await writeFile(join(blockRoot, block.template), block.html);
}
await writeFile(contentPath, `${JSON.stringify(page, null, 2)}\n`);
console.log(`Extracted ${blocks.length} blocks from ${sourcePath}`);
console.log(`Wrote ${join(templateRoot, "shell.html")}`);
console.log(`Wrote ${contentPath}`);
+105
View File
@@ -0,0 +1,105 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const pagePath = join(repoRoot, "content", "pages", "home.json");
const templateRoot = join(repoRoot, "templates", "pages", "home");
const shellPath = join(templateRoot, "shell.html");
const blockRoot = join(templateRoot, "blocks");
const REGION_TOKEN = {
beforeCViz: "{{NODEDC_BLOCKS_BEFORE_CVIZ}}",
cViz: "{{NODEDC_BLOCKS_CVIZ}}",
};
const page = JSON.parse(await readFile(pagePath, "utf8"));
const shell = await readFile(shellPath, "utf8");
const templateCache = new Map();
async function readTemplate(templateName) {
if (!templateName || templateName.includes("..") || templateName.includes("/")) {
throw new Error(`Unsafe template name: ${templateName}`);
}
if (!templateCache.has(templateName)) {
templateCache.set(templateName, await readFile(join(blockRoot, templateName), "utf8"));
}
return templateCache.get(templateName);
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function scopeDuplicateIds(html, scope) {
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
if (!ids.length) {
return html;
}
let scoped = html.replace(/\sid="([^"]+)"/g, (_match, id) => ` id="${scope}-${id}"`);
for (const id of ids) {
const escaped = escapeRegExp(id);
scoped = scoped.replace(
new RegExp(`\\s(data-target|for|aria-controls|aria-labelledby|aria-describedby)="${escaped}"`, "g"),
(_match, attr) => ` ${attr}="${scope}-${id}"`,
);
scoped = scoped.replace(new RegExp(`(href="[^"]*)#${escaped}(")`, "g"), `$1#${scope}-${id}$2`);
}
return scoped;
}
async function renderBlock(block, renderCounts) {
if (block.enabled === false) {
return "";
}
const templateName = block.template;
const renderCount = renderCounts.get(templateName) ?? 0;
renderCounts.set(templateName, renderCount + 1);
const html = await readTemplate(templateName);
if (renderCount === 0 && block.scopeIds !== true) {
return html;
}
return scopeDuplicateIds(html, block.id);
}
async function renderRegion(region) {
const renderCounts = new Map();
const blocks = [];
for (const block of region.blocks ?? []) {
blocks.push(await renderBlock(block, renderCounts));
}
return blocks.join("");
}
let output = shell;
for (const region of page.regions ?? []) {
const token = REGION_TOKEN[region.id];
if (!token) {
throw new Error(`Unknown region: ${region.id}`);
}
output = output.replace(token, await renderRegion(region));
}
for (const token of Object.values(REGION_TOKEN)) {
if (output.includes(token)) {
throw new Error(`Unrendered token left in shell: ${token}`);
}
}
const outputPath = join(repoRoot, page.output || "index.html");
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, output);
console.log(`Rendered ${page.slug} -> ${outputPath}`);