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

View File

@ -13,6 +13,37 @@ python3 -m http.server 8081 --bind 127.0.0.1
Open http://127.0.0.1:8081/
## Admin And Render
The first refactor step keeps the Webflow markup intact, but moves the home page into a block-rendered structure:
- `content/pages/home.json` - ordered page regions and block instances.
- `templates/pages/home/shell.html` - original page shell with block slots.
- `templates/pages/home/blocks/` - exact HTML chunks extracted from the current page.
- `tools/render-home.mjs` - renders `index.html` from the content model and templates.
- `admin/` - local admin UI for block order, enable/disable, duplicate, copy/paste, and JSON edits.
Run the local admin:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SITE
npm run admin
```
Open http://127.0.0.1:8090/admin/
Render the public page after JSON changes:
```bash
npm run render:home
```
If the current `index.html` must be re-extracted into fresh templates:
```bash
npm run extract:home
```
## Structure
- `index.html`, `beta-apply.html`, `founders.html`, `enterprise.html` - editable local pages.
@ -22,6 +53,7 @@ Open http://127.0.0.1:8081/
- `assets/vendor` - third-party browser scripts.
- `docs/` - refactor notes, block model draft, and admin plan.
- `originals` - untouched files copied from the original wget download.
- `backbase/ssscript.app` - original downloaded app bundle backup.
- `asset-manifest.json` - source URL to local file mapping.
- `tools/build-local-copy.mjs` - rebuild script. It uses `NODEDC_SOURCE_DIR` when provided, otherwise the original local rescue folder.
- `tools/localize-ru.mjs` - Russian localization and NODE.DC brand patch script.

268
admin/admin.css Normal file
View File

@ -0,0 +1,268 @@
:root {
color-scheme: dark;
--bg: #08090b;
--panel: #121417;
--panel-2: #181b20;
--line: #2a2f36;
--text: #f4f5f6;
--muted: #9097a1;
--accent: #1976ff;
--danger: #cf3b3b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
textarea {
font: inherit;
}
.admin-shell {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
border-right: 1px solid var(--line);
background: #0d0f12;
padding: 20px;
overflow: auto;
}
.sidebar-head,
.editor-head,
.actions,
.button-row,
.toggle-row {
display: flex;
align-items: center;
}
.sidebar-head,
.editor-head {
justify-content: space-between;
gap: 16px;
}
.eyebrow {
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2 {
margin: 4px 0 0;
font-size: 24px;
line-height: 1.05;
letter-spacing: 0;
}
.regions {
display: grid;
gap: 18px;
margin-top: 24px;
}
.region-title {
margin: 0 0 8px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.block-list {
display: grid;
gap: 8px;
}
.block-btn {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--text);
cursor: pointer;
padding: 11px 12px;
text-align: left;
}
.block-btn:hover,
.block-btn.active {
border-color: var(--accent);
}
.block-btn.disabled {
color: #5f6670;
}
.block-meta {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.editor {
min-width: 0;
padding: 24px;
}
.editor-head {
border-bottom: 1px solid var(--line);
padding-bottom: 20px;
}
.actions,
.button-row {
gap: 8px;
flex-wrap: wrap;
}
.primary-btn,
.ghost-btn,
.danger-btn,
.icon-btn {
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
cursor: pointer;
min-height: 36px;
padding: 8px 12px;
text-decoration: none;
}
.primary-btn {
border-color: var(--accent);
background: var(--accent);
}
.ghost-btn,
.icon-btn {
background: var(--panel-2);
}
.danger-btn {
border-color: #5d2525;
background: #241010;
color: #ffb9b9;
}
.icon-btn {
width: 36px;
padding: 0;
}
.empty-state {
margin-top: 24px;
max-width: 680px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--muted);
line-height: 1.45;
padding: 16px;
}
.block-form {
display: grid;
gap: 18px;
margin-top: 24px;
}
.hidden {
display: none;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
label {
display: grid;
gap: 8px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--text);
outline: none;
padding: 10px 12px;
}
input:focus,
textarea:focus {
border-color: var(--accent);
}
.toggle-row {
justify-content: flex-start;
gap: 10px;
}
.toggle-row input {
width: 18px;
height: 18px;
}
.json-field textarea {
min-height: 360px;
resize: vertical;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 13px;
line-height: 1.45;
}
.status {
min-height: 24px;
margin: 18px 0 0;
color: var(--muted);
white-space: pre-wrap;
}
@media (max-width: 900px) {
.admin-shell {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
max-height: 48vh;
}
.editor-head {
align-items: flex-start;
flex-direction: column;
}
.field-grid {
grid-template-columns: 1fr;
}
}

265
admin/admin.js Normal file
View File

@ -0,0 +1,265 @@
const state = {
page: null,
selected: null,
clipboard: null,
};
const el = {
regions: document.querySelector("#regions"),
reload: document.querySelector("#reload"),
save: document.querySelector("#save"),
render: document.querySelector("#render"),
status: document.querySelector("#status"),
empty: document.querySelector("#empty-state"),
form: document.querySelector("#block-form"),
selectedRegion: document.querySelector("#selected-region"),
selectedTitle: document.querySelector("#selected-title"),
id: document.querySelector("#block-id"),
label: document.querySelector("#block-label"),
template: document.querySelector("#block-template"),
anchor: document.querySelector("#block-anchor"),
enabled: document.querySelector("#block-enabled"),
json: document.querySelector("#block-json"),
moveUp: document.querySelector("#move-up"),
moveDown: document.querySelector("#move-down"),
duplicate: document.querySelector("#duplicate"),
copy: document.querySelector("#copy"),
pasteAfter: document.querySelector("#paste-after"),
deleteBlock: document.querySelector("#delete-block"),
};
function setStatus(message) {
el.status.textContent = message;
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function activeRegion() {
if (!state.selected) return null;
return state.page.regions[state.selected.regionIndex];
}
function activeBlock() {
const region = activeRegion();
if (!region || state.selected.blockIndex == null) return null;
return region.blocks[state.selected.blockIndex];
}
function uniqueId(base) {
const ids = new Set(state.page.regions.flatMap((region) => region.blocks.map((block) => block.id)));
let candidate = `${base}-copy`;
let index = 2;
while (ids.has(candidate)) {
candidate = `${base}-copy-${index}`;
index += 1;
}
return candidate;
}
function renderRegions() {
el.regions.innerHTML = "";
state.page.regions.forEach((region, regionIndex) => {
const regionNode = document.createElement("section");
const title = document.createElement("h3");
const list = document.createElement("div");
title.className = "region-title";
title.textContent = region.label || region.id;
list.className = "block-list";
region.blocks.forEach((block, blockIndex) => {
const button = document.createElement("button");
const meta = document.createElement("span");
const isActive =
state.selected?.regionIndex === regionIndex && state.selected?.blockIndex === blockIndex;
button.type = "button";
button.className = `block-btn${isActive ? " active" : ""}${block.enabled === false ? " disabled" : ""}`;
button.textContent = block.adminLabel || block.id;
meta.className = "block-meta";
meta.textContent = `${block.id} · ${block.template}`;
button.append(meta);
button.addEventListener("click", () => {
state.selected = { regionIndex, blockIndex };
renderAll();
});
list.append(button);
});
regionNode.append(title, list);
el.regions.append(regionNode);
});
}
function renderEditor() {
const region = activeRegion();
const block = activeBlock();
if (!block) {
el.empty.classList.remove("hidden");
el.form.classList.add("hidden");
el.selectedRegion.textContent = "Выберите блок";
el.selectedTitle.textContent = "Контентный слой";
return;
}
el.empty.classList.add("hidden");
el.form.classList.remove("hidden");
el.selectedRegion.textContent = region.label || region.id;
el.selectedTitle.textContent = block.adminLabel || block.id;
el.id.value = block.id || "";
el.label.value = block.adminLabel || "";
el.template.value = block.template || "";
el.anchor.value = block.anchor || "";
el.enabled.checked = block.enabled !== false;
el.json.value = JSON.stringify(block, null, 2);
}
function renderAll() {
renderRegions();
renderEditor();
}
function syncBlockFromFields() {
const block = activeBlock();
if (!block) return;
block.id = el.id.value.trim();
block.adminLabel = el.label.value.trim();
block.anchor = el.anchor.value.trim() || null;
block.enabled = el.enabled.checked;
el.json.value = JSON.stringify(block, null, 2);
renderRegions();
}
function syncBlockFromJson() {
const region = activeRegion();
if (!region) return false;
try {
const parsed = JSON.parse(el.json.value);
region.blocks[state.selected.blockIndex] = parsed;
renderAll();
setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл.");
return true;
} catch (error) {
setStatus(`Ошибка JSON: ${error.message}`);
return false;
}
}
async function loadPage() {
const response = await fetch("/api/page/home");
if (!response.ok) throw new Error(await response.text());
state.page = await response.json();
state.selected = null;
renderAll();
setStatus("Модель home.json загружена.");
}
async function savePage() {
if (activeBlock()) syncBlockFromFields();
const response = await fetch("/api/page/home", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(state.page),
});
if (!response.ok) throw new Error(await response.text());
setStatus("content/pages/home.json сохранён.");
}
async function renderHome() {
const response = await fetch("/api/render/home", { method: "POST" });
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error || "Render failed");
}
setStatus(payload.stdout || "index.html собран.");
}
function moveSelected(delta) {
const region = activeRegion();
const index = state.selected?.blockIndex;
if (!region || index == null) return;
const nextIndex = index + delta;
if (nextIndex < 0 || nextIndex >= region.blocks.length) return;
const [block] = region.blocks.splice(index, 1);
region.blocks.splice(nextIndex, 0, block);
state.selected.blockIndex = nextIndex;
renderAll();
}
function duplicateSelected() {
const region = activeRegion();
const block = activeBlock();
if (!region || !block) return;
const duplicate = clone(block);
duplicate.id = uniqueId(block.id);
duplicate.adminLabel = `${block.adminLabel || block.id} — копия`;
duplicate.scopeIds = true;
region.blocks.splice(state.selected.blockIndex + 1, 0, duplicate);
state.selected.blockIndex += 1;
renderAll();
}
function copySelected() {
const block = activeBlock();
if (!block) return;
state.clipboard = clone(block);
setStatus(`Скопирован блок: ${block.adminLabel || block.id}`);
}
function pasteAfterSelected() {
const region = activeRegion();
if (!region || !state.clipboard) return;
const pasted = clone(state.clipboard);
pasted.id = uniqueId(pasted.id);
pasted.adminLabel = `${pasted.adminLabel || pasted.id} — вставка`;
pasted.scopeIds = true;
region.blocks.splice(state.selected.blockIndex + 1, 0, pasted);
state.selected.blockIndex += 1;
renderAll();
}
function deleteSelected() {
const region = activeRegion();
if (!region || state.selected.blockIndex == null) return;
region.blocks.splice(state.selected.blockIndex, 1);
state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1);
if (state.selected.blockIndex < 0) state.selected = null;
renderAll();
}
for (const input of [el.id, el.label, el.anchor, el.enabled]) {
input.addEventListener("input", syncBlockFromFields);
input.addEventListener("change", syncBlockFromFields);
}
el.json.addEventListener("blur", syncBlockFromJson);
el.reload.addEventListener("click", () => loadPage().catch((error) => setStatus(error.message)));
el.save.addEventListener("click", () => savePage().catch((error) => setStatus(error.message)));
el.render.addEventListener("click", () => renderHome().catch((error) => setStatus(error.message)));
el.moveUp.addEventListener("click", () => moveSelected(-1));
el.moveDown.addEventListener("click", () => moveSelected(1));
el.duplicate.addEventListener("click", duplicateSelected);
el.copy.addEventListener("click", copySelected);
el.pasteAfter.addEventListener("click", pasteAfterSelected);
el.deleteBlock.addEventListener("click", deleteSelected);
loadPage().catch((error) => setStatus(error.message));

85
admin/index.html Normal file
View File

@ -0,0 +1,85 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NODE.DC Admin</title>
<link rel="stylesheet" href="./admin.css" />
</head>
<body>
<main class="admin-shell">
<aside class="sidebar">
<div class="sidebar-head">
<div>
<div class="eyebrow">NODE.DC</div>
<h1>Блоки страницы</h1>
</div>
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные"></button>
</div>
<div id="regions" class="regions"></div>
</aside>
<section class="editor">
<header class="editor-head">
<div>
<div id="selected-region" class="eyebrow">Выберите блок</div>
<h2 id="selected-title">Контентный слой</h2>
</div>
<div class="actions">
<a class="ghost-btn" href="/" target="_blank">Открыть сайт</a>
<button id="render" class="ghost-btn" type="button">Собрать index.html</button>
<button id="save" class="primary-btn" type="button">Сохранить JSON</button>
</div>
</header>
<div id="empty-state" class="empty-state">
Слева выберите блок. Сейчас это первый этап: блоки можно включать, выключать,
дублировать и менять порядок внутри своего DOM-региона.
</div>
<form id="block-form" class="block-form hidden">
<div class="field-grid">
<label>
ID блока
<input id="block-id" name="id" type="text" autocomplete="off" />
</label>
<label>
Название в админке
<input id="block-label" name="adminLabel" type="text" autocomplete="off" />
</label>
<label>
Шаблон
<input id="block-template" name="template" type="text" autocomplete="off" readonly />
</label>
<label>
Anchor
<input id="block-anchor" name="anchor" type="text" autocomplete="off" />
</label>
</div>
<label class="toggle-row">
<input id="block-enabled" name="enabled" type="checkbox" />
<span>Блок включён в рендер</span>
</label>
<div class="button-row">
<button id="move-up" class="ghost-btn" type="button">Вверх</button>
<button id="move-down" class="ghost-btn" type="button">Вниз</button>
<button id="duplicate" class="ghost-btn" type="button">Дублировать</button>
<button id="copy" class="ghost-btn" type="button">Копировать</button>
<button id="paste-after" class="ghost-btn" type="button">Вставить после</button>
<button id="delete-block" class="danger-btn" type="button">Удалить</button>
</div>
<label class="json-field">
JSON выбранного блока
<textarea id="block-json" spellcheck="false"></textarea>
</label>
</form>
<pre id="status" class="status"></pre>
</section>
</main>
<script src="./admin.js" type="module"></script>
</body>
</html>

89
content/pages/home.json Normal file
View File

@ -0,0 +1,89 @@
{
"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": [
{
"region": "beforeCViz",
"id": "hero-waitlist",
"type": "staticHtml",
"template": "hero-waitlist.html",
"adminLabel": "Первый экран и заявка",
"anchor": "hero",
"enabled": true
},
{
"region": "beforeCViz",
"id": "feature-video",
"type": "staticHtml",
"template": "feature-video.html",
"adminLabel": "Демо и базовая логика",
"anchor": "features",
"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": [
{
"region": "cViz",
"id": "product-system",
"type": "staticHtml",
"template": "product-system.html",
"adminLabel": "Компоненты, режимы, FAQ и тарифная таблица",
"anchor": null,
"enabled": true
},
{
"region": "cViz",
"id": "pricing-intro",
"type": "staticHtml",
"template": "pricing-intro.html",
"adminLabel": "Интро тарифов",
"anchor": "waitlist",
"enabled": true
},
{
"region": "cViz",
"id": "waitlist-files",
"type": "staticHtml",
"template": "waitlist-files.html",
"adminLabel": "Форма ожидания и txt-файлы",
"anchor": "waitlist",
"enabled": true
},
{
"region": "cViz",
"id": "demo-video-folder",
"type": "staticHtml",
"template": "demo-video-folder.html",
"adminLabel": "Иконка демо-видео в доке",
"anchor": null,
"enabled": true
},
{
"region": "cViz",
"id": "footer",
"type": "staticHtml",
"template": "footer.html",
"adminLabel": "Футер",
"anchor": null,
"enabled": true
}
]
}
]
}

View File

@ -166,6 +166,28 @@ Global non-block content:
5. Add Payload CMS only after static JSON rendering is visually stable.
6. Optimize media and runtime after block rendering is stable.
## Phase 1 Implementation Status
Started on June 25, 2026.
Implemented:
- `content/pages/home.json` contains the first ordered block model for the home page.
- `templates/pages/home/shell.html` contains the current Webflow shell with two render slots.
- `templates/pages/home/blocks/` contains exact HTML block chunks extracted from the current localized page.
- `tools/render-home.mjs` renders `index.html` from the JSON model and templates.
- `tools/extract-home-blocks.mjs` can regenerate the first template split from the current `index.html`.
- `tools/admin-server.mjs` and `admin/` provide a local block editor.
Current guarantee:
- Rendering through `npm run render:home` has byte-for-byte parity with the pre-refactor `index.html`.
Current limitation:
- Blocks are still HTML-template blocks. The admin can manage order, enabled state, duplication, copy/paste, IDs, labels, anchors, and raw block JSON. The next pass must extract text/media fields from each block template into typed JSON fields.
- The `c-viz` wrapper is preserved as a region boundary because it is coupled to WebGL/color-flip behavior. Blocks inside that region should stay inside it until the runtime is better understood.
## Optimization Passes
Do after structural parity, not before:

11
package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "nodedc-site",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"extract:home": "node tools/extract-home-blocks.mjs",
"render:home": "node tools/render-home.mjs",
"admin": "node tools/admin-server.mjs"
}
}

View File

@ -0,0 +1 @@
<div data-module="folder-wrap" class="folder-w video-w"><div tabindex="0" data-target="demo-video" data-to="features" data-module="apple-icon" class="folder"><img src="./assets/webflow/images/69aab66f030013f2c7177d78_mov-file.avif" loading="eager" data-icon="" alt="Иконка MOV-файла." class="image"/><div class="folder-title-w"><div class="dot highlight purple"></div><div data-txt="" class="folder-title">демо-видео.txt</div></div></div></div>

View File

@ -0,0 +1 @@
<section id="features" class="s h-screen py mobile-flip"><div class="app-w align-right"><div id="demo-video" data-module="app" class="app-item video"><div data-draggable-area="" class="app-head preview"><div class="app-head-trigs"><button data-close="" aria-label="закрыть" class="circle opaque"><div class="x-icon"><div class="line full x-icon rot"></div><div class="line full x-icon"></div></div></button><div class="circle opaque"></div></div><div class="app-name-w"><div class="app-name-txt preview">демо-видео.mp4</div></div></div><div class="video-size"><video src="./assets/media/publish-flow.mp4" autoplay="true" muted="" loop="" playsinline="true" class="video"></video></div></div></div><div class="c"><hgroup data-module="hg"><h2 class="main-h h1"><span class="darker-40">Всё в одном</span><span>приложение.</span></h2><div class="h-txt">Универсальный инструмент для <span class="darker-70">ИИ-агентов</span> в вашем Webflow-проекте.</div><div class="txt-w"><div class="hg-par darker-40">Контекст проекта</div><div class="h-txt">Подключитесь к <span class="darker-70">MCP</span> чтобы агенты получили полный контекст вашей сборки.</div><div class="hg-par darker-40">Группы агентов</div><div class="h-txt">Используйте <span class="darker-70">несколько агентов параллельно</span> чтобы добавлять кастомный код, анимации и функциональность.</div><div class="hg-par darker-40">Среда разработки</div><div class="h-txt">Используйте быстрые <span class="darker-70">Bun</span> сборки и автообновление для вашего <span class="darker-70">кастомного кода.</span></div><div class="hg-par darker-40">Деплой в один клик</div><div class="h-txt"><span class="darker-70">Нажмите и публикуйте</span> кастомный код, написанный ИИ, чтобы он стал доступен всем посетителям сайта.</div></div></hgroup></div></section>

View File

@ -0,0 +1 @@
<footer class="s footer"><div class="c footer-col"><div class="div-block-9"><div class="div-block-2"><img data-module="mouse-follow" loading="lazy" alt="Иконка NODE.DC" src="./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg" class="logo app footer"/><img loading="lazy" src="./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc" alt="Логотип NODE.DC" class="logo-size"/></div><div class="footer-notice mt">© 2026<a href="https://federic.ooo/" target="_blank"></a> —  <a href="https://federic.ooo/" target="_blank">NODE.DC</a></div></div><div class="footer-cols"><div class="div-block-3"><h2 class="footer-list-he">Навигация</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled"><li><a data-module="link" href="https://docs.ssscript.app/" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>Документация</div></div></a></li><li><a data-module="link" href="index.html#waitlist" class="ft-link w-inline-block"><div class="clip-12"><div>Лист ожидания</div></div></a></li><li><a data-module="link" href="index.html#" class="ft-link w-inline-block"><div class="clip-12"><div>Тарифы</div></div></a></li><li><a data-module="link" href="index.html#features" class="ft-link w-inline-block"><div class="clip-12"><div>Возможности</div></div></a></li><li><a data-module="link" href="index.html#features" class="ft-link w-inline-block"><div class="clip-12"><div>Демо</div></div></a></li></ul></div><div class="div-block-3"><h2 data-module="text-wave" class="footer-list-he">Контакты</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled"><li><a data-module="link" href="https://federic.ooo/s/twitter" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>Twitter</div></div></a></li><li><a data-module="link" href="https://federic.ooo/s/instagram" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>Instagram</div></div></a></li><li><a data-module="link" href="https://www.federic.ooo/s/github" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>GitHub</div></div></a></li><li><a data-module="link" href="https://federic.ooo/" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>Портфолио</div></div></a></li></ul></div></div></div><div class="c footer-col"><div class="footer-notice mt"><a href="https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing" target="_blank">Политика конфиденциальности</a> &amp; <a href="https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing" target="_blank">Условия использования</a></div></div></footer>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
<section id="waitlist" class="s"><div class="c center ve"><hgroup data-module="hg" class="ce"><div data-module="text-wave" class="hg-par darker-70">Тарифы</div><h2 class="main-h h1 xl"><span class="darker-40">Код</span><span class="darker-70">для No-Code,</span><span>усиленный ИИ.</span></h2><div class="div-block-11"><div class="h-txt">Подписки на приложение с<br/>включённым объёмом в каждом тарифе. <br/></div><div class="h-txt"><span class="darker-70">Токенная</span> оплата сверх лимитов.</div><div class="h-txt darker-40"><span class="darker-70">200 Founder</span> мест с фиксированной ценой на первый год.</div></div></hgroup></div></section>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

151
tools/admin-server.mjs Normal file
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}/`);
});

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
tools/render-home.mjs Normal file
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}`);