Expose typed content fields for first home blocks

This commit is contained in:
dcconstructions
2026-06-25 22:03:35 +03:00
parent aaae2da433
commit 69678a441e
10 changed files with 672 additions and 9 deletions
+182 -3
View File
@@ -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 = {
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`);
+36 -1
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
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;