NODEDC_SITE/tools/extract-home-blocks.mjs

541 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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");
let html = await readFile(sourcePath, "utf8");
html = html.replace(/\n*<script id="node-dc-static-config"(?: type="application\/json")?>[\s\S]*?<\/script>\n*/g, "\n");
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;
let 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),
},
];
function getByPath(source, path) {
return path.split(".").reduce((value, key) => value?.[key], source);
}
function inferFieldGroup(path) {
if (path.startsWith("content.navigation.")) return "Шапка";
if (path.startsWith("content.notifications.")) return "Уведомления";
if (path.startsWith("content.dock.")) return "Dock";
if (path.startsWith("content.assets.")) return "Модель и логотипы";
if (path.startsWith("content.cards.")) return "Тезисы первого экрана";
if (path.startsWith("content.app.")) return "Карточка раннего доступа";
if (path.startsWith("content.form.")) return "Форма";
if (path === "content.legalHtml") return "Юридический блок";
if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно";
if (path.startsWith("content.features.")) return "Пункты";
if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок";
return "Контент";
}
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null) {
return { path, label, kind, renderAs, group: group || inferFieldGroup(path) };
}
function tokenizeBlock(block, definition) {
let html = block.html;
for (const editableField of definition.editableFields) {
const value = getByPath({ content: definition.content }, editableField.path);
const token = `{{${editableField.renderAs}:${editableField.path}}}`;
if (typeof value !== "string") {
throw new Error(`Token field must be a string: ${block.id}.${editableField.path}`);
}
if (!html.includes(value)) {
throw new Error(`Could not find "${value}" while tokenizing ${block.id}`);
}
html = html.replace(value, token);
}
return {
...block,
type: definition.type,
html,
content: definition.content,
editableFields: definition.editableFields,
};
}
const typedBlockDefinitions = {
"hero-waitlist": {
type: "heroWaitlist",
content: {
heading: {
muted: "Ваш Webflow ",
main: "ИИ-инструменты",
},
introHtml:
'Несколько агентов для проекта Webflow: от <span class="darker-70">Designer</span> до <span class="darker-70">кастомного кода.</span>',
cards: [
{
muted: "Полный",
main: "агентный контроль",
bodyHtml:
'Интеграция с <span class="darker-70">Webflow MCP,</span> мультиагентный режим позволяет работать с <span class="darker-70">несколькими экземплярами</span> параллельно.',
},
{
muted: "Компонентная",
main: "библиотека",
bodyHtml:
'Собирайте с учетом <span class="darker-70">ИИ-агентов</span> разные компонентные заготовки дают основу, а стиль и сложную функциональность можно дорабатывать промптами.',
},
{
muted: "Деплой ",
main: "пайплайн",
bodyHtml:
'<span class="darker-70">Деплой в один клик,</span> непрерывный пайплайн, мгновенный откат и быстрая <span class="darker-70">доставка через CDN.</span>',
},
],
app: {
iconAlt: "Иконка NODE.DC",
logoAlt: "Логотип NODE.DC",
release: "Бета-релиз: июль 2026",
heading: "Ранний доступ",
description: "Кастомный код, MCP и ИИ-агенты для Webflow в одном рабочем контуре.",
},
form: {
messagePlaceholder: "Сообщение (необязательно)",
namePlaceholder: "Ваше имя*",
emailPlaceholder: "Email для регистрации*",
submitLabel: "Встать в лист ожидания",
successHeading: "Заявка принята. Спасибо.",
successTextHtml: "Мы скоро свяжемся. <br/>",
betaCtaLabel: "Подать заявку в бету?",
founderIntroHtml: "Или оформить ранний Founder-доступ<br/>",
founderCtaHtml: 'Оформить Founder-доступ <span class="darker-70">350€</span>',
spotsLabel: "До 200 мест",
failureHeading: "Не получилось",
failureTextHtml: "Что-то пошло не так. <br/>Попробуйте ещё раз чуть позже.<br/>",
},
legalHtml:
'<a href="https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing" target="_blank">Политика конфиденциальности</a> • <a href="https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing" target="_blank">Политика cookie</a><br/>© — Все права защищены<br/>2026',
},
editableFields: [
field("content.heading.muted", "Hero: приглушённая строка"),
field("content.heading.main", "Hero: основная строка"),
field("content.introHtml", "Hero: вводный текст", "html"),
field("content.cards.0.muted", "Карточка 1: приглушённая строка"),
field("content.cards.0.main", "Карточка 1: основная строка"),
field("content.cards.0.bodyHtml", "Карточка 1: текст", "html"),
field("content.cards.1.muted", "Карточка 2: приглушённая строка"),
field("content.cards.1.main", "Карточка 2: основная строка"),
field("content.cards.1.bodyHtml", "Карточка 2: текст", "html"),
field("content.cards.2.muted", "Карточка 3: приглушённая строка"),
field("content.cards.2.main", "Карточка 3: основная строка"),
field("content.cards.2.bodyHtml", "Карточка 3: текст", "html"),
field("content.app.iconAlt", "App: alt иконки", "text", "attr"),
field("content.app.logoAlt", "App: alt логотипа", "text", "attr"),
field("content.app.release", "App: релиз"),
field("content.app.heading", "App: заголовок"),
field("content.app.description", "App: описание"),
field("content.form.messagePlaceholder", "Форма: placeholder сообщения", "text", "attr"),
field("content.form.namePlaceholder", "Форма: placeholder имени", "text", "attr"),
field("content.form.emailPlaceholder", "Форма: placeholder email", "text", "attr"),
field("content.form.submitLabel", "Форма: кнопка"),
field("content.form.successHeading", "Форма: успех, заголовок"),
field("content.form.successTextHtml", "Форма: успех, текст", "html"),
field("content.form.betaCtaLabel", "Форма: CTA беты"),
field("content.form.founderIntroHtml", "Форма: Founder intro", "html"),
field("content.form.founderCtaHtml", "Форма: Founder CTA", "html"),
field("content.form.spotsLabel", "Форма: лимит мест"),
field("content.form.failureHeading", "Форма: ошибка, заголовок"),
field("content.form.failureTextHtml", "Форма: ошибка, текст", "html"),
field("content.legalHtml", "Юридический текст", "html"),
],
},
"feature-video": {
type: "featureVideo",
content: {
windowTitle: "демо-видео.mp4",
videoSrc: "./assets/media/publish-flow.mp4",
heading: {
muted: "Всё в одном",
main: "приложение.",
},
introHtml:
'Универсальный инструмент для <span class="darker-70">ИИ-агентов</span> в вашем Webflow-проекте.',
features: [
{
eyebrow: "Контекст проекта",
bodyHtml:
'Подключитесь к <span class="darker-70">MCP</span> чтобы агенты получили полный контекст вашей сборки.',
},
{
eyebrow: "Группы агентов",
bodyHtml:
'Используйте <span class="darker-70">несколько агентов параллельно</span> чтобы добавлять кастомный код, анимации и функциональность.',
},
{
eyebrow: "Среда разработки",
bodyHtml:
'Используйте быстрые <span class="darker-70">Bun</span> сборки и автообновление для вашего <span class="darker-70">кастомного кода.</span>',
},
{
eyebrow: "Деплой в один клик",
bodyHtml:
'<span class="darker-70">Нажмите и публикуйте</span> кастомный код, написанный ИИ, чтобы он стал доступен всем посетителям сайта.',
},
],
},
editableFields: [
field("content.windowTitle", "Окно: название файла"),
field("content.videoSrc", "Окно: видео", "media", "attr"),
field("content.heading.muted", "Заголовок: приглушённая строка"),
field("content.heading.main", "Заголовок: основная строка"),
field("content.introHtml", "Вводный текст", "html"),
field("content.features.0.eyebrow", "Пункт 1: подпись"),
field("content.features.0.bodyHtml", "Пункт 1: текст", "html"),
field("content.features.1.eyebrow", "Пункт 2: подпись"),
field("content.features.1.bodyHtml", "Пункт 2: текст", "html"),
field("content.features.2.eyebrow", "Пункт 3: подпись"),
field("content.features.2.bodyHtml", "Пункт 3: текст", "html"),
field("content.features.3.eyebrow", "Пункт 4: подпись"),
field("content.features.3.bodyHtml", "Пункт 4: текст", "html"),
],
},
};
const staticElements = {
id: "static-elements",
type: "staticElements",
adminLabel: "Статичные элементы",
enabled: true,
content: {
navigation: {
logoAlt: "главная",
brandLabel: "NODE.DC",
links: [
{
label: "О продукте",
href: "index.html#features",
target: "",
},
{
label: "Лист ожидания",
href: "index.html#hero",
target: "",
},
{
label: "Документация",
href: "https://docs.ssscript.app/",
target: "_blank",
},
{
label: "Тарифы",
href: "index.html#pricing-wrap",
target: "",
},
],
},
notifications: [
{
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
title: "Открытая бета",
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
},
{
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
title: "Альфа для Founder-доступа",
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
},
{
eyebrowHtml: "СЕЙЧАС",
title: "Инвайты в закрытую бету уже отправлены.",
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
},
],
dock: {
appTarget: "app-data",
trashAlt: "Белая иконка корзины.",
demoFileLabel: "демо-видео.txt",
},
assets: {
webglModelSrc: "./assets/custom/icon.glb",
logoPng: "./assets/custom/logo.png",
logoPngAlt: "Логотип NODE.DC",
appIconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
appIconAlt: "Иконка NODE.DC",
appWordmarkSrc: "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
appWordmarkAlt: "Логотип NODE.DC",
},
},
editableFields: [
field("content.navigation.logoAlt", "Логотип в шапке: alt"),
field("content.navigation.brandLabel", "Бренд в шапке"),
field("content.navigation.links.0.label", "Ссылка 1: текст"),
field("content.navigation.links.0.href", "Ссылка 1: адрес", "url", "attr"),
field("content.navigation.links.1.label", "Ссылка 2: текст"),
field("content.navigation.links.1.href", "Ссылка 2: адрес", "url", "attr"),
field("content.navigation.links.2.label", "Ссылка 3: текст"),
field("content.navigation.links.2.href", "Ссылка 3: адрес", "url", "attr"),
field("content.navigation.links.2.target", "Ссылка 3: target", "text", "attr"),
field("content.navigation.links.3.label", "Ссылка 4: текст"),
field("content.navigation.links.3.href", "Ссылка 4: адрес", "url", "attr"),
field("content.notifications.0.eyebrowHtml", "Уведомление 1: дата/статус", "html"),
field("content.notifications.0.title", "Уведомление 1: заголовок"),
field("content.notifications.0.body", "Уведомление 1: текст", "textarea"),
field("content.notifications.1.eyebrowHtml", "Уведомление 2: дата/статус", "html"),
field("content.notifications.1.title", "Уведомление 2: заголовок"),
field("content.notifications.1.body", "Уведомление 2: текст", "textarea"),
field("content.notifications.2.eyebrowHtml", "Уведомление 3: дата/статус", "html"),
field("content.notifications.2.title", "Уведомление 3: заголовок"),
field("content.notifications.2.body", "Уведомление 3: текст", "textarea"),
field("content.dock.appTarget", "Dock: ID приложения"),
field("content.dock.trashAlt", "Dock: alt корзины"),
field("content.dock.demoFileLabel", "Dock: подпись демо-файла"),
field("content.assets.webglModelSrc", "WebGL-модель логотипа", "media", "attr"),
field("content.assets.logoPng", "PNG-логотип", "media", "attr"),
field("content.assets.logoPngAlt", "PNG-логотип: alt"),
field("content.assets.appIconSrc", "Иконка приложения", "media", "attr"),
field("content.assets.appIconAlt", "Иконка приложения: alt"),
field("content.assets.appWordmarkSrc", "Wordmark приложения", "media", "attr"),
field("content.assets.appWordmarkAlt", "Wordmark приложения: alt"),
],
};
function shellToken(mode, path) {
return `{{${mode}:staticElements.${path}}}`;
}
function replaceShellLiteral(source, literal, replacement, label) {
if (!source.includes(literal)) {
throw new Error(`Could not find shell fragment while tokenizing static elements: ${label}`);
}
return source.replace(literal, replacement);
}
function tokenizeStaticShell(source, definition) {
const { navigation, notifications, dock } = definition.content;
let tokenized = source;
tokenized = replaceShellLiteral(
tokenized,
`aria-label="${navigation.logoAlt}" aria-current`,
`aria-label="${shellToken("attr", "content.navigation.logoAlt")}" aria-current`,
"navigation.logoAlt",
);
tokenized = replaceShellLiteral(
tokenized,
`class="bar-txt medium w--current">${navigation.brandLabel}</a>`,
`class="bar-txt medium w--current">${shellToken("text", "content.navigation.brandLabel")}</a>`,
"navigation.brandLabel",
);
for (const [index, link] of navigation.links.entries()) {
const targetPart = link.target ? ` target="${link.target}"` : "";
tokenized = replaceShellLiteral(
tokenized,
`<a href="${link.href}"${targetPart} class="bar-txt">${link.label}</a>`,
`<a href="${shellToken("attr", `content.navigation.links.${index}.href`)}"${
link.target ? ` target="${shellToken("attr", `content.navigation.links.${index}.target`)}"` : ""
} class="bar-txt">${shellToken("text", `content.navigation.links.${index}.label`)}</a>`,
`navigation.links.${index}`,
);
}
notifications.forEach((notification, index) => {
tokenized = replaceShellLiteral(
tokenized,
notification.eyebrowHtml,
shellToken("html", `content.notifications.${index}.eyebrowHtml`),
`notifications.${index}.eyebrowHtml`,
);
tokenized = replaceShellLiteral(
tokenized,
`<div>${notification.title}</div>`,
`<div>${shellToken("text", `content.notifications.${index}.title`)}</div>`,
`notifications.${index}.title`,
);
tokenized = replaceShellLiteral(
tokenized,
`<div>${notification.body}</div>`,
`<div>${shellToken("text", `content.notifications.${index}.body`)}</div>`,
`notifications.${index}.body`,
);
});
tokenized = replaceShellLiteral(
tokenized,
`data-target="${dock.appTarget}" class="dock-icon ssscript"`,
`data-target="${shellToken("attr", "content.dock.appTarget")}" class="dock-icon ssscript"`,
"dock.appTarget",
);
tokenized = replaceShellLiteral(
tokenized,
`alt="${dock.trashAlt}" class="dock-app-img trash"`,
`alt="${shellToken("attr", "content.dock.trashAlt")}" class="dock-app-img trash"`,
"dock.trashAlt",
);
tokenized = replaceShellLiteral(
tokenized,
"<script>\nwindow.__API_ORIGIN__=",
`<script id="node-dc-static-config" type="application/json">{"assets":{"icon":"${shellToken(
"js",
"content.assets.webglModelSrc",
)}","logo":"${shellToken("js", "content.assets.logoPng")}"}}</script>\n<script>\nwindow.__API_ORIGIN__=`,
"static asset config",
);
return tokenized;
}
shell = tokenizeStaticShell(shell, staticElements);
const preparedBlocks = blocks.map((block) => {
const definition = typedBlockDefinitions[block.id];
return definition ? tokenizeBlock(block, definition) : block;
});
const page = {
schemaVersion: 1,
slug: "home",
output: "index.html",
title: "NODE.DC",
staticElements,
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: preparedBlocks
.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: preparedBlocks
.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 preparedBlocks) {
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}`);