Add static home block renderer and local admin
This commit is contained in:
@@ -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}/`);
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
Reference in New Issue
Block a user