Refine admin editor around static content

This commit is contained in:
dcconstructions
2026-06-25 23:10:48 +03:00
parent 69678a441e
commit d0020a48e3
9 changed files with 1487 additions and 265 deletions
+210 -4
View File
@@ -8,7 +8,8 @@ const templateRoot = join(repoRoot, "templates", "pages", "home");
const blockRoot = join(templateRoot, "blocks");
const contentPath = join(repoRoot, "content", "pages", "home.json");
const html = await readFile(sourcePath, "utf8");
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">',
@@ -38,7 +39,7 @@ const demoVideoFolderStart = findRequired(marker.demoVideoFolder, waitlistFilesS
const footerStart = findRequired(marker.footer, demoVideoFolderStart);
const footerEnd = findRequired("</footer>", footerStart) + "</footer>".length;
const shell = [
let shell = [
html.slice(0, heroStart),
"{{NODEDC_BLOCKS_BEFORE_CVIZ}}",
html.slice(cVizStart, cVizOpenEnd),
@@ -116,8 +117,23 @@ 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 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) {
@@ -286,6 +302,195 @@ const typedBlockDefinitions = {
},
};
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;
@@ -296,6 +501,7 @@ const page = {
slug: "home",
output: "index.html",
title: "NODE.DC",
staticElements,
seo: {
title: "NODE.DC — ИИ-платформа для Webflow",
description:
+17 -2
View File
@@ -48,8 +48,19 @@ function escapeAttr(value) {
return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
function escapeJsString(value) {
return String(value)
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"')
.replaceAll("\n", "\\n")
.replaceAll("\r", "\\r")
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029")
.replaceAll("</script", "<\\/script");
}
function renderTemplateTokens(html, block) {
return html.replace(/\{\{\s*(text|html|attr):([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, mode, path) => {
return html.replace(/\{\{\s*(text|html|attr|js):([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, mode, path) => {
const value = getByPath(block, path);
if (value === undefined) {
@@ -64,6 +75,10 @@ function renderTemplateTokens(html, block) {
return value == null ? "" : escapeAttr(value);
}
if (mode === "js") {
return value == null ? "" : escapeJsString(value);
}
return value == null ? "" : escapeHtml(value);
});
}
@@ -117,7 +132,7 @@ async function renderRegion(region) {
return blocks.join("");
}
let output = shell;
let output = renderTemplateTokens(shell, { id: page.slug, ...page });
for (const region of page.regions ?? []) {
const token = REGION_TOKEN[region.id];
if (!token) {