Expose typed content fields for first home blocks
This commit is contained in:
parent
aaae2da433
commit
69678a441e
|
|
@ -23,6 +23,8 @@ The first refactor step keeps the Webflow markup intact, but moves the home page
|
||||||
- `tools/render-home.mjs` - renders `index.html` from the content model and templates.
|
- `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.
|
- `admin/` - local admin UI for block order, enable/disable, duplicate, copy/paste, and JSON edits.
|
||||||
|
|
||||||
|
`heroWaitlist` and `featureVideo` already expose typed content fields in the admin. Other blocks are still managed as HTML-template blocks until their content fields are extracted.
|
||||||
|
|
||||||
Run the local admin:
|
Run the local admin:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,37 @@ h2 {
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-fields .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-fields .empty-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -265,4 +296,8 @@ textarea:focus {
|
||||||
.field-grid {
|
.field-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const el = {
|
||||||
template: document.querySelector("#block-template"),
|
template: document.querySelector("#block-template"),
|
||||||
anchor: document.querySelector("#block-anchor"),
|
anchor: document.querySelector("#block-anchor"),
|
||||||
enabled: document.querySelector("#block-enabled"),
|
enabled: document.querySelector("#block-enabled"),
|
||||||
|
contentFields: document.querySelector("#content-fields"),
|
||||||
json: document.querySelector("#block-json"),
|
json: document.querySelector("#block-json"),
|
||||||
moveUp: document.querySelector("#move-up"),
|
moveUp: document.querySelector("#move-up"),
|
||||||
moveDown: document.querySelector("#move-down"),
|
moveDown: document.querySelector("#move-down"),
|
||||||
|
|
@ -36,6 +37,25 @@ function clone(value) {
|
||||||
return JSON.parse(JSON.stringify(value));
|
return JSON.parse(JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getByPath(source, path) {
|
||||||
|
return path.split(".").reduce((value, key) => value?.[key], source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setByPath(source, path, nextValue) {
|
||||||
|
const keys = path.split(".");
|
||||||
|
const lastKey = keys.pop();
|
||||||
|
let target = source;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (target[key] == null) {
|
||||||
|
target[key] = /^\d+$/.test(keys[0]) ? [] : {};
|
||||||
|
}
|
||||||
|
target = target[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
target[lastKey] = nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
function activeRegion() {
|
function activeRegion() {
|
||||||
if (!state.selected) return null;
|
if (!state.selected) return null;
|
||||||
return state.page.regions[state.selected.regionIndex];
|
return state.page.regions[state.selected.regionIndex];
|
||||||
|
|
@ -118,6 +138,7 @@ function renderEditor() {
|
||||||
el.template.value = block.template || "";
|
el.template.value = block.template || "";
|
||||||
el.anchor.value = block.anchor || "";
|
el.anchor.value = block.anchor || "";
|
||||||
el.enabled.checked = block.enabled !== false;
|
el.enabled.checked = block.enabled !== false;
|
||||||
|
renderContentFields(block);
|
||||||
el.json.value = JSON.stringify(block, null, 2);
|
el.json.value = JSON.stringify(block, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,6 +151,7 @@ function syncBlockFromFields() {
|
||||||
const block = activeBlock();
|
const block = activeBlock();
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
|
|
||||||
|
syncContentFields();
|
||||||
block.id = el.id.value.trim();
|
block.id = el.id.value.trim();
|
||||||
block.adminLabel = el.label.value.trim();
|
block.adminLabel = el.label.value.trim();
|
||||||
block.anchor = el.anchor.value.trim() || null;
|
block.anchor = el.anchor.value.trim() || null;
|
||||||
|
|
@ -138,6 +160,54 @@ function syncBlockFromFields() {
|
||||||
renderRegions();
|
renderRegions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderContentFields(block) {
|
||||||
|
el.contentFields.innerHTML = "";
|
||||||
|
|
||||||
|
if (!block.editableFields?.length) {
|
||||||
|
const note = document.createElement("div");
|
||||||
|
note.className = "empty-note";
|
||||||
|
note.textContent = "Для этого блока typed-поля ещё не выделены. Пока доступно редактирование через JSON.";
|
||||||
|
el.contentFields.append(note);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const editableField of block.editableFields) {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
const value = getByPath(block, editableField.path) ?? "";
|
||||||
|
const isLong = editableField.kind === "html" || editableField.kind === "textarea";
|
||||||
|
const input = isLong ? document.createElement("textarea") : document.createElement("input");
|
||||||
|
|
||||||
|
label.className = isLong ? "wide" : "";
|
||||||
|
label.textContent = editableField.label || editableField.path;
|
||||||
|
input.dataset.path = editableField.path;
|
||||||
|
input.value = value;
|
||||||
|
|
||||||
|
if (!isLong) {
|
||||||
|
input.type = editableField.kind === "media" || editableField.kind === "url" ? "text" : "text";
|
||||||
|
input.autocomplete = "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
const active = activeBlock();
|
||||||
|
if (!active) return;
|
||||||
|
setByPath(active, editableField.path, input.value);
|
||||||
|
el.json.value = JSON.stringify(active, null, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
label.append(input);
|
||||||
|
el.contentFields.append(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncContentFields() {
|
||||||
|
const block = activeBlock();
|
||||||
|
if (!block) return;
|
||||||
|
|
||||||
|
for (const input of el.contentFields.querySelectorAll("[data-path]")) {
|
||||||
|
setByPath(block, input.dataset.path, input.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function syncBlockFromJson() {
|
function syncBlockFromJson() {
|
||||||
const region = activeRegion();
|
const region = activeRegion();
|
||||||
if (!region) return false;
|
if (!region) return false;
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@
|
||||||
<button id="delete-block" class="danger-btn" type="button">Удалить</button>
|
<button id="delete-block" class="danger-btn" type="button">Удалить</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="content-panel">
|
||||||
|
<div class="section-title">Контент блока</div>
|
||||||
|
<div id="content-fields" class="content-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<label class="json-field">
|
<label class="json-field">
|
||||||
JSON выбранного блока
|
JSON выбранного блока
|
||||||
<textarea id="block-json" spellcheck="false"></textarea>
|
<textarea id="block-json" spellcheck="false"></textarea>
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,354 @@
|
||||||
{
|
{
|
||||||
"region": "beforeCViz",
|
"region": "beforeCViz",
|
||||||
"id": "hero-waitlist",
|
"id": "hero-waitlist",
|
||||||
"type": "staticHtml",
|
"type": "heroWaitlist",
|
||||||
"template": "hero-waitlist.html",
|
"template": "hero-waitlist.html",
|
||||||
"adminLabel": "Первый экран и заявка",
|
"adminLabel": "Первый экран и заявка",
|
||||||
"anchor": "hero",
|
"anchor": "hero",
|
||||||
|
"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": [
|
||||||
|
{
|
||||||
|
"path": "content.heading.muted",
|
||||||
|
"label": "Hero: приглушённая строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.heading.main",
|
||||||
|
"label": "Hero: основная строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.introHtml",
|
||||||
|
"label": "Hero: вводный текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.cards.0.muted",
|
||||||
|
"label": "Карточка 1: приглушённая строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.cards.0.main",
|
||||||
|
"label": "Карточка 1: основная строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.cards.0.bodyHtml",
|
||||||
|
"label": "Карточка 1: текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.cards.1.muted",
|
||||||
|
"label": "Карточка 2: приглушённая строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.cards.1.main",
|
||||||
|
"label": "Карточка 2: основная строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.cards.1.bodyHtml",
|
||||||
|
"label": "Карточка 2: текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.cards.2.muted",
|
||||||
|
"label": "Карточка 3: приглушённая строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.cards.2.main",
|
||||||
|
"label": "Карточка 3: основная строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.cards.2.bodyHtml",
|
||||||
|
"label": "Карточка 3: текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.app.iconAlt",
|
||||||
|
"label": "App: alt иконки",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "attr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.app.logoAlt",
|
||||||
|
"label": "App: alt логотипа",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "attr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.app.release",
|
||||||
|
"label": "App: релиз",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.app.heading",
|
||||||
|
"label": "App: заголовок",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.app.description",
|
||||||
|
"label": "App: описание",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.messagePlaceholder",
|
||||||
|
"label": "Форма: placeholder сообщения",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "attr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.namePlaceholder",
|
||||||
|
"label": "Форма: placeholder имени",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "attr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.emailPlaceholder",
|
||||||
|
"label": "Форма: placeholder email",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "attr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.submitLabel",
|
||||||
|
"label": "Форма: кнопка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.successHeading",
|
||||||
|
"label": "Форма: успех, заголовок",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.successTextHtml",
|
||||||
|
"label": "Форма: успех, текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.betaCtaLabel",
|
||||||
|
"label": "Форма: CTA беты",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.founderIntroHtml",
|
||||||
|
"label": "Форма: Founder intro",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.founderCtaHtml",
|
||||||
|
"label": "Форма: Founder CTA",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.spotsLabel",
|
||||||
|
"label": "Форма: лимит мест",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.failureHeading",
|
||||||
|
"label": "Форма: ошибка, заголовок",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.form.failureTextHtml",
|
||||||
|
"label": "Форма: ошибка, текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.legalHtml",
|
||||||
|
"label": "Юридический текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
}
|
||||||
|
],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"region": "beforeCViz",
|
"region": "beforeCViz",
|
||||||
"id": "feature-video",
|
"id": "feature-video",
|
||||||
"type": "staticHtml",
|
"type": "featureVideo",
|
||||||
"template": "feature-video.html",
|
"template": "feature-video.html",
|
||||||
"adminLabel": "Демо и базовая логика",
|
"adminLabel": "Демо и базовая логика",
|
||||||
"anchor": "features",
|
"anchor": "features",
|
||||||
|
"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": [
|
||||||
|
{
|
||||||
|
"path": "content.windowTitle",
|
||||||
|
"label": "Окно: название файла",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.videoSrc",
|
||||||
|
"label": "Окно: видео",
|
||||||
|
"kind": "media",
|
||||||
|
"renderAs": "attr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.heading.muted",
|
||||||
|
"label": "Заголовок: приглушённая строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.heading.main",
|
||||||
|
"label": "Заголовок: основная строка",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.introHtml",
|
||||||
|
"label": "Вводный текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.features.0.eyebrow",
|
||||||
|
"label": "Пункт 1: подпись",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.features.0.bodyHtml",
|
||||||
|
"label": "Пункт 1: текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.features.1.eyebrow",
|
||||||
|
"label": "Пункт 2: подпись",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.features.1.bodyHtml",
|
||||||
|
"label": "Пункт 2: текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.features.2.eyebrow",
|
||||||
|
"label": "Пункт 3: подпись",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.features.2.bodyHtml",
|
||||||
|
"label": "Пункт 3: текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.features.3.eyebrow",
|
||||||
|
"label": "Пункт 4: подпись",
|
||||||
|
"kind": "text",
|
||||||
|
"renderAs": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "content.features.3.bodyHtml",
|
||||||
|
"label": "Пункт 4: текст",
|
||||||
|
"kind": "html",
|
||||||
|
"renderAs": "html"
|
||||||
|
}
|
||||||
|
],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,8 @@ Implemented:
|
||||||
- `tools/render-home.mjs` renders `index.html` from the JSON model and templates.
|
- `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/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.
|
- `tools/admin-server.mjs` and `admin/` provide a local block editor.
|
||||||
|
- `heroWaitlist` and `featureVideo` expose typed content fields in JSON and the local admin.
|
||||||
|
- Block templates support `{{text:path}}`, `{{html:path}}`, and `{{attr:path}}` tokens rendered by `tools/render-home.mjs`.
|
||||||
|
|
||||||
Current guarantee:
|
Current guarantee:
|
||||||
|
|
||||||
|
|
@ -185,7 +187,7 @@ Current guarantee:
|
||||||
|
|
||||||
Current limitation:
|
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.
|
- Only the first two home blocks have typed fields so far. The remaining blocks are still HTML-template blocks. The admin can manage order, enabled state, duplication, copy/paste, IDs, labels, anchors, typed fields where available, and raw block JSON.
|
||||||
- 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.
|
- 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
|
## Optimization Passes
|
||||||
|
|
|
||||||
|
|
@ -1 +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>
|
<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">{{text:content.windowTitle}}</div></div></div><div class="video-size"><video src="{{attr:content.videoSrc}}" 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">{{text:content.heading.muted}}</span><span>{{text:content.heading.main}}</span></h2><div class="h-txt">{{html:content.introHtml}}</div><div class="txt-w"><div class="hg-par darker-40">{{text:content.features.0.eyebrow}}</div><div class="h-txt">{{html:content.features.0.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.features.1.eyebrow}}</div><div class="h-txt">{{html:content.features.1.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.features.2.eyebrow}}</div><div class="h-txt">{{html:content.features.2.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.features.3.eyebrow}}</div><div class="h-txt">{{html:content.features.3.bodyHtml}}</div></div></hgroup></div></section>
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -112,6 +112,185 @@ const blocks = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function getByPath(source, path) {
|
||||||
|
return path.split(".").reduce((value, key) => value?.[key], source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text") {
|
||||||
|
return { path, label, kind, renderAs };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 preparedBlocks = blocks.map((block) => {
|
||||||
|
const definition = typedBlockDefinitions[block.id];
|
||||||
|
return definition ? tokenizeBlock(block, definition) : block;
|
||||||
|
});
|
||||||
|
|
||||||
const page = {
|
const page = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
slug: "home",
|
slug: "home",
|
||||||
|
|
@ -127,7 +306,7 @@ const page = {
|
||||||
id: "beforeCViz",
|
id: "beforeCViz",
|
||||||
label: "До c-viz wrapper",
|
label: "До c-viz wrapper",
|
||||||
description: "Hero and first product section. These blocks live before the visual wrapper.",
|
description: "Hero and first product section. These blocks live before the visual wrapper.",
|
||||||
blocks: blocks
|
blocks: preparedBlocks
|
||||||
.filter((block) => block.region === "beforeCViz")
|
.filter((block) => block.region === "beforeCViz")
|
||||||
.map(({ html: _html, ...block }) => ({ ...block, enabled: true })),
|
.map(({ html: _html, ...block }) => ({ ...block, enabled: true })),
|
||||||
},
|
},
|
||||||
|
|
@ -135,7 +314,7 @@ const page = {
|
||||||
id: "cViz",
|
id: "cViz",
|
||||||
label: "Inside c-viz wrapper",
|
label: "Inside c-viz wrapper",
|
||||||
description: "Sections coupled to WebGL/color-flip shell. Keep these in this region for now.",
|
description: "Sections coupled to WebGL/color-flip shell. Keep these in this region for now.",
|
||||||
blocks: blocks
|
blocks: preparedBlocks
|
||||||
.filter((block) => block.region === "cViz")
|
.filter((block) => block.region === "cViz")
|
||||||
.map(({ html: _html, ...block }) => ({ ...block, enabled: true })),
|
.map(({ html: _html, ...block }) => ({ ...block, enabled: true })),
|
||||||
},
|
},
|
||||||
|
|
@ -145,7 +324,7 @@ const page = {
|
||||||
await mkdir(blockRoot, { recursive: true });
|
await mkdir(blockRoot, { recursive: true });
|
||||||
await mkdir(dirname(contentPath), { recursive: true });
|
await mkdir(dirname(contentPath), { recursive: true });
|
||||||
await writeFile(join(templateRoot, "shell.html"), shell);
|
await writeFile(join(templateRoot, "shell.html"), shell);
|
||||||
for (const block of blocks) {
|
for (const block of preparedBlocks) {
|
||||||
await writeFile(join(blockRoot, block.template), block.html);
|
await writeFile(join(blockRoot, block.template), block.html);
|
||||||
}
|
}
|
||||||
await writeFile(contentPath, `${JSON.stringify(page, null, 2)}\n`);
|
await writeFile(contentPath, `${JSON.stringify(page, null, 2)}\n`);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,41 @@ function escapeRegExp(value) {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getByPath(source, path) {
|
||||||
|
return path.split(".").reduce((value, key) => value?.[key], source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplateTokens(html, block) {
|
||||||
|
return html.replace(/\{\{\s*(text|html|attr):([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, mode, path) => {
|
||||||
|
const value = getByPath(block, path);
|
||||||
|
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error(`Missing token value "${path}" in block "${block.id}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "html") {
|
||||||
|
return value == null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "attr") {
|
||||||
|
return value == null ? "" : escapeAttr(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value == null ? "" : escapeHtml(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function scopeDuplicateIds(html, scope) {
|
function scopeDuplicateIds(html, scope) {
|
||||||
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
|
|
@ -62,7 +97,7 @@ async function renderBlock(block, renderCounts) {
|
||||||
const renderCount = renderCounts.get(templateName) ?? 0;
|
const renderCount = renderCounts.get(templateName) ?? 0;
|
||||||
renderCounts.set(templateName, renderCount + 1);
|
renderCounts.set(templateName, renderCount + 1);
|
||||||
|
|
||||||
const html = await readTemplate(templateName);
|
const html = renderTemplateTokens(await readTemplate(templateName), block);
|
||||||
|
|
||||||
if (renderCount === 0 && block.scopeIds !== true) {
|
if (renderCount === 0 && block.scopeIds !== true) {
|
||||||
return html;
|
return html;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue