diff --git a/README.md b/README.md index 71c0d4c..6623897 100644 --- a/README.md +++ b/README.md @@ -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. - `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: ```bash diff --git a/admin/admin.css b/admin/admin.css index 80d45e3..ba12ddf 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -197,6 +197,37 @@ h2 { 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 { display: grid; gap: 8px; @@ -265,4 +296,8 @@ textarea:focus { .field-grid { grid-template-columns: 1fr; } + + .content-fields { + grid-template-columns: 1fr; + } } diff --git a/admin/admin.js b/admin/admin.js index 460d3a7..dfc4caa 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -19,6 +19,7 @@ const el = { template: document.querySelector("#block-template"), anchor: document.querySelector("#block-anchor"), enabled: document.querySelector("#block-enabled"), + contentFields: document.querySelector("#content-fields"), json: document.querySelector("#block-json"), moveUp: document.querySelector("#move-up"), moveDown: document.querySelector("#move-down"), @@ -36,6 +37,25 @@ function clone(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() { if (!state.selected) return null; return state.page.regions[state.selected.regionIndex]; @@ -118,6 +138,7 @@ function renderEditor() { el.template.value = block.template || ""; el.anchor.value = block.anchor || ""; el.enabled.checked = block.enabled !== false; + renderContentFields(block); el.json.value = JSON.stringify(block, null, 2); } @@ -130,6 +151,7 @@ function syncBlockFromFields() { const block = activeBlock(); if (!block) return; + syncContentFields(); block.id = el.id.value.trim(); block.adminLabel = el.label.value.trim(); block.anchor = el.anchor.value.trim() || null; @@ -138,6 +160,54 @@ function syncBlockFromFields() { 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() { const region = activeRegion(); if (!region) return false; diff --git a/admin/index.html b/admin/index.html index 4bfc4da..42b6fed 100644 --- a/admin/index.html +++ b/admin/index.html @@ -71,6 +71,11 @@ Удалить + + Контент блока + + + JSON выбранного блока diff --git a/content/pages/home.json b/content/pages/home.json index 7ad844d..cd9e153 100644 --- a/content/pages/home.json +++ b/content/pages/home.json @@ -16,19 +16,354 @@ { "region": "beforeCViz", "id": "hero-waitlist", - "type": "staticHtml", + "type": "heroWaitlist", "template": "hero-waitlist.html", "adminLabel": "Первый экран и заявка", "anchor": "hero", + "content": { + "heading": { + "muted": "Ваш Webflow ", + "main": "ИИ-инструменты" + }, + "introHtml": "Несколько агентов для проекта Webflow: от Designer до кастомного кода.", + "cards": [ + { + "muted": "Полный", + "main": "агентный контроль", + "bodyHtml": "Интеграция с Webflow MCP, мультиагентный режим позволяет работать с несколькими экземплярами параллельно." + }, + { + "muted": "Компонентная", + "main": "библиотека", + "bodyHtml": "Собирайте с учетом ИИ-агентов разные компонентные заготовки дают основу, а стиль и сложную функциональность можно дорабатывать промптами." + }, + { + "muted": "Деплой ", + "main": "пайплайн", + "bodyHtml": "Деплой в один клик, непрерывный пайплайн, мгновенный откат и быстрая доставка через CDN." + } + ], + "app": { + "iconAlt": "Иконка NODE.DC", + "logoAlt": "Логотип NODE.DC", + "release": "Бета-релиз: июль 2026", + "heading": "Ранний доступ", + "description": "Кастомный код, MCP и ИИ-агенты для Webflow в одном рабочем контуре." + }, + "form": { + "messagePlaceholder": "Сообщение (необязательно)", + "namePlaceholder": "Ваше имя*", + "emailPlaceholder": "Email для регистрации*", + "submitLabel": "Встать в лист ожидания", + "successHeading": "Заявка принята. Спасибо.", + "successTextHtml": "Мы скоро свяжемся. ", + "betaCtaLabel": "Подать заявку в бету?", + "founderIntroHtml": "Или оформить ранний Founder-доступ", + "founderCtaHtml": "Оформить Founder-доступ 350€", + "spotsLabel": "До 200 мест", + "failureHeading": "Не получилось", + "failureTextHtml": "Что-то пошло не так. Попробуйте ещё раз чуть позже." + }, + "legalHtml": "Политика конфиденциальности • Политика cookie© — Все права защищены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 }, { "region": "beforeCViz", "id": "feature-video", - "type": "staticHtml", + "type": "featureVideo", "template": "feature-video.html", "adminLabel": "Демо и базовая логика", "anchor": "features", + "content": { + "windowTitle": "демо-видео.mp4", + "videoSrc": "./assets/media/publish-flow.mp4", + "heading": { + "muted": "Всё в одном", + "main": "приложение." + }, + "introHtml": "Универсальный инструмент для ИИ-агентов в вашем Webflow-проекте.", + "features": [ + { + "eyebrow": "Контекст проекта", + "bodyHtml": "Подключитесь к MCP чтобы агенты получили полный контекст вашей сборки." + }, + { + "eyebrow": "Группы агентов", + "bodyHtml": "Используйте несколько агентов параллельно чтобы добавлять кастомный код, анимации и функциональность." + }, + { + "eyebrow": "Среда разработки", + "bodyHtml": "Используйте быстрые Bun сборки и автообновление для вашего кастомного кода." + }, + { + "eyebrow": "Деплой в один клик", + "bodyHtml": "Нажмите и публикуйте кастомный код, написанный ИИ, чтобы он стал доступен всем посетителям сайта." + } + ] + }, + "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 } ] diff --git a/docs/refactor-admin-plan.md b/docs/refactor-admin-plan.md index 9d016df..b0f06f2 100644 --- a/docs/refactor-admin-plan.md +++ b/docs/refactor-admin-plan.md @@ -178,6 +178,8 @@ Implemented: - `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. +- `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: @@ -185,7 +187,7 @@ Current guarantee: 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. ## Optimization Passes diff --git a/templates/pages/home/blocks/feature-video.html b/templates/pages/home/blocks/feature-video.html index cf028df..6d77af3 100644 --- a/templates/pages/home/blocks/feature-video.html +++ b/templates/pages/home/blocks/feature-video.html @@ -1 +1 @@ -демо-видео.mp4Всё в одномприложение.Универсальный инструмент для ИИ-агентов в вашем Webflow-проекте.Контекст проектаПодключитесь к MCP чтобы агенты получили полный контекст вашей сборки.Группы агентовИспользуйте несколько агентов параллельно чтобы добавлять кастомный код, анимации и функциональность.Среда разработкиИспользуйте быстрые Bun сборки и автообновление для вашего кастомного кода.Деплой в один кликНажмите и публикуйте кастомный код, написанный ИИ, чтобы он стал доступен всем посетителям сайта. \ No newline at end of file +{{text:content.windowTitle}}{{text:content.heading.muted}}{{text:content.heading.main}}{{html:content.introHtml}}{{text:content.features.0.eyebrow}}{{html:content.features.0.bodyHtml}}{{text:content.features.1.eyebrow}}{{html:content.features.1.bodyHtml}}{{text:content.features.2.eyebrow}}{{html:content.features.2.bodyHtml}}{{text:content.features.3.eyebrow}}{{html:content.features.3.bodyHtml}} \ No newline at end of file diff --git a/templates/pages/home/blocks/hero-waitlist.html b/templates/pages/home/blocks/hero-waitlist.html index b0c8b6c..3c8e25c 100644 --- a/templates/pages/home/blocks/hero-waitlist.html +++ b/templates/pages/home/blocks/hero-waitlist.html @@ -1 +1 @@ -Ваш Webflow ИИ-инструментыНесколько агентов для проекта Webflow: от Designer до кастомного кода.Полныйагентный контрольИнтеграция с Webflow MCP, мультиагентный режим позволяет работать с несколькими экземплярами параллельно.КомпонентнаябиблиотекаСобирайте с учетом ИИ-агентов разные компонентные заготовки дают основу, а стиль и сложную функциональность можно дорабатывать промптами.Деплой пайплайнДеплой в один клик, непрерывный пайплайн, мгновенный откат и быстрая доставка через CDN.Бета-релиз: июль 2026Ранний доступКастомный код, MCP и ИИ-агенты для Webflow в одном рабочем контуре.Встать в лист ожиданияЗаявка принята. Спасибо.Мы скоро свяжемся. Подать заявку в бету?Или оформить ранний Founder-доступОформить Founder-доступ 350€До 200 местНе получилосьЧто-то пошло не так. Попробуйте ещё раз чуть позже.Политика конфиденциальности • Политика cookie© — Все права защищены2026 \ No newline at end of file +{{text:content.heading.muted}}{{text:content.heading.main}}{{html:content.introHtml}}{{text:content.cards.0.muted}}{{text:content.cards.0.main}}{{html:content.cards.0.bodyHtml}}{{text:content.cards.1.muted}}{{text:content.cards.1.main}}{{html:content.cards.1.bodyHtml}}{{text:content.cards.2.muted}}{{text:content.cards.2.main}}{{html:content.cards.2.bodyHtml}}{{text:content.app.release}}{{text:content.app.heading}}{{text:content.app.description}}{{text:content.form.submitLabel}}{{text:content.form.successHeading}}{{html:content.form.successTextHtml}}{{text:content.form.betaCtaLabel}}{{html:content.form.founderIntroHtml}}{{html:content.form.founderCtaHtml}}{{text:content.form.spotsLabel}}{{text:content.form.failureHeading}}{{html:content.form.failureTextHtml}}{{html:content.legalHtml}} \ No newline at end of file diff --git a/tools/extract-home-blocks.mjs b/tools/extract-home-blocks.mjs index a143088..7f1af0d 100644 --- a/tools/extract-home-blocks.mjs +++ b/tools/extract-home-blocks.mjs @@ -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: от Designer до кастомного кода.', + cards: [ + { + muted: "Полный", + main: "агентный контроль", + bodyHtml: + 'Интеграция с Webflow MCP, мультиагентный режим позволяет работать с несколькими экземплярами параллельно.', + }, + { + muted: "Компонентная", + main: "библиотека", + bodyHtml: + 'Собирайте с учетом ИИ-агентов разные компонентные заготовки дают основу, а стиль и сложную функциональность можно дорабатывать промптами.', + }, + { + muted: "Деплой ", + main: "пайплайн", + bodyHtml: + 'Деплой в один клик, непрерывный пайплайн, мгновенный откат и быстрая доставка через CDN.', + }, + ], + app: { + iconAlt: "Иконка NODE.DC", + logoAlt: "Логотип NODE.DC", + release: "Бета-релиз: июль 2026", + heading: "Ранний доступ", + description: "Кастомный код, MCP и ИИ-агенты для Webflow в одном рабочем контуре.", + }, + form: { + messagePlaceholder: "Сообщение (необязательно)", + namePlaceholder: "Ваше имя*", + emailPlaceholder: "Email для регистрации*", + submitLabel: "Встать в лист ожидания", + successHeading: "Заявка принята. Спасибо.", + successTextHtml: "Мы скоро свяжемся. ", + betaCtaLabel: "Подать заявку в бету?", + founderIntroHtml: "Или оформить ранний Founder-доступ", + founderCtaHtml: 'Оформить Founder-доступ 350€', + spotsLabel: "До 200 мест", + failureHeading: "Не получилось", + failureTextHtml: "Что-то пошло не так. Попробуйте ещё раз чуть позже.", + }, + legalHtml: + 'Политика конфиденциальности • Политика cookie© — Все права защищены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: + 'Универсальный инструмент для ИИ-агентов в вашем Webflow-проекте.', + features: [ + { + eyebrow: "Контекст проекта", + bodyHtml: + 'Подключитесь к MCP чтобы агенты получили полный контекст вашей сборки.', + }, + { + eyebrow: "Группы агентов", + bodyHtml: + 'Используйте несколько агентов параллельно чтобы добавлять кастомный код, анимации и функциональность.', + }, + { + eyebrow: "Среда разработки", + bodyHtml: + 'Используйте быстрые Bun сборки и автообновление для вашего кастомного кода.', + }, + { + eyebrow: "Деплой в один клик", + bodyHtml: + 'Нажмите и публикуйте кастомный код, написанный ИИ, чтобы он стал доступен всем посетителям сайта.', + }, + ], + }, + 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 = { schemaVersion: 1, slug: "home", @@ -127,7 +306,7 @@ const page = { id: "beforeCViz", label: "До c-viz wrapper", description: "Hero and first product section. These blocks live before the visual wrapper.", - blocks: blocks + blocks: preparedBlocks .filter((block) => block.region === "beforeCViz") .map(({ html: _html, ...block }) => ({ ...block, enabled: true })), }, @@ -135,7 +314,7 @@ const page = { id: "cViz", label: "Inside c-viz wrapper", description: "Sections coupled to WebGL/color-flip shell. Keep these in this region for now.", - blocks: blocks + blocks: preparedBlocks .filter((block) => block.region === "cViz") .map(({ html: _html, ...block }) => ({ ...block, enabled: true })), }, @@ -145,7 +324,7 @@ const page = { await mkdir(blockRoot, { recursive: true }); await mkdir(dirname(contentPath), { recursive: true }); 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(contentPath, `${JSON.stringify(page, null, 2)}\n`); diff --git a/tools/render-home.mjs b/tools/render-home.mjs index cd9119f..2f0298a 100644 --- a/tools/render-home.mjs +++ b/tools/render-home.mjs @@ -33,6 +33,41 @@ function escapeRegExp(value) { 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) { const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); if (!ids.length) { @@ -62,7 +97,7 @@ async function renderBlock(block, renderCounts) { const renderCount = renderCounts.get(templateName) ?? 0; renderCounts.set(templateName, renderCount + 1); - const html = await readTemplate(templateName); + const html = renderTemplateTokens(await readTemplate(templateName), block); if (renderCount === 0 && block.scopeIds !== true) { return html;