Refine admin editor around static content
This commit is contained in:
@@ -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
@@ -48,8 +48,19 @@ function escapeAttr(value) {
|
||||
return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user