Compare commits
3 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
2c07dc2df8 | |
|
|
e08ad18253 | |
|
|
0e13de36cf |
|
|
@ -6,18 +6,17 @@ Static promo site for NODE.DC: an enterprise platform for processes, data, AI ag
|
|||
|
||||
- `content/pages/home.json` - structured content model for the homepage.
|
||||
- `templates/pages/home/shell.html` - homepage shell and shared head/runtime includes.
|
||||
- `templates/pages/home/blocks` - block templates used by the renderer and admin.
|
||||
- `content/block-templates/home.json` - sanitized admin block presets.
|
||||
- `templates/pages/home/blocks` - block templates used by the renderer.
|
||||
- `content/block-templates/home.json` - sanitized block presets consumed by the external CMS.
|
||||
- `assets/site` - local runtime CSS, JS, fonts, and base UI images.
|
||||
- `assets/custom` - NODE.DC custom runtime and overrides.
|
||||
- `assets/uploads` - product media managed by the site/admin flow.
|
||||
- `admin` and `tools/admin-server.mjs` - local content admin.
|
||||
- `assets/uploads` - product media managed by the external CMS flow.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run render:home
|
||||
npm run admin
|
||||
npm run render:knowledge
|
||||
```
|
||||
|
||||
For a static preview:
|
||||
|
|
|
|||
2935
admin/admin.css
2935
admin/admin.css
File diff suppressed because it is too large
Load Diff
5442
admin/admin.js
5442
admin/admin.js
File diff suppressed because it is too large
Load Diff
344
admin/index.html
344
admin/index.html
|
|
@ -1,344 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NODE.DC Admin</title>
|
||||
<link rel="stylesheet" href="./admin.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-backdrop" aria-hidden="true"></div>
|
||||
<header class="nodedc-expanded-toolbar-shell">
|
||||
<div class="nodedc-expanded-toolbar">
|
||||
<div class="nodedc-expanded-toolbar-top">
|
||||
<div class="nodedc-expanded-toolbar-left">
|
||||
<a href="/" class="nodedc-expanded-brand-link" target="_blank" aria-label="NODE.DC">
|
||||
<img src="./nodedc-logo.svg" alt="NODE.DC" class="nodedc-expanded-brand-logo" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="nodedc-expanded-toolbar-center">
|
||||
<span class="nodedc-expanded-workspace-button" aria-hidden="true">
|
||||
<img src="../assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png" alt="" class="nodedc-expanded-workspace-avatar" />
|
||||
</span>
|
||||
<nav class="nodedc-expanded-nav-group" aria-label="Навигация админки">
|
||||
<button id="admin-mode-content" class="nodedc-expanded-nav-button" type="button" data-active="true">
|
||||
<span>Администрирование</span>
|
||||
</button>
|
||||
<button id="admin-mode-seo" class="nodedc-expanded-nav-button" type="button" data-active="false">
|
||||
<span>SEO</span>
|
||||
</button>
|
||||
<button id="admin-mode-knowledge" class="nodedc-expanded-nav-button" type="button" data-active="false">
|
||||
<span>База знаний</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="nodedc-expanded-toolbar-right">
|
||||
<div class="nodedc-expanded-user-group">
|
||||
<span class="nodedc-toolbar-icon-button" aria-hidden="true">▣</span>
|
||||
<span class="nodedc-expanded-profile-trigger">
|
||||
<span class="nodedc-expanded-nav-button" data-active="false">
|
||||
<span>Профиль</span>
|
||||
</span>
|
||||
<span class="nodedc-expanded-user-avatar-button" aria-hidden="true">
|
||||
<span class="nodedc-expanded-user-avatar">DC</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="admin-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-head">
|
||||
<div>
|
||||
<div class="section-kicker">NODE.DC</div>
|
||||
<h1 id="sidebar-title">Администрирование</h1>
|
||||
</div>
|
||||
<button id="add-block" class="admin-panel-close add-block-button" type="button" aria-label="Добавить блок">+</button>
|
||||
<div class="workspace-card admin-panel-client-select">
|
||||
<div class="workspace-logo admin-panel-client-select__icon">DC</div>
|
||||
<div>
|
||||
<div class="workspace-name">NODE.DC</div>
|
||||
<div id="workspace-role" class="workspace-role">Администратор сайта</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="regions" class="regions"></div>
|
||||
</aside>
|
||||
|
||||
<section class="editor">
|
||||
<header class="editor-head">
|
||||
<div>
|
||||
<h1 id="editor-heading">Настройки сайта</h1>
|
||||
<div id="selected-region" class="breadcrumb">NODE.DC / Выберите блок</div>
|
||||
<h2 id="selected-title">Контентный слой</h2>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span id="dirty-state" class="dirty-state" data-dirty="false">Синхронизировано</span>
|
||||
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
||||
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</button>
|
||||
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="empty-state" class="empty-state">
|
||||
<div class="empty-icon">ND</div>
|
||||
<div>
|
||||
<h3>Выберите блок слева</h3>
|
||||
<p>Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="block-form" class="block-form hidden">
|
||||
<section class="settings-card block-meta-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="section-kicker">Идентификатор</div>
|
||||
<h3>Служебные параметры</h3>
|
||||
</div>
|
||||
<label class="toggle-row">
|
||||
<input id="block-enabled" name="enabled" type="checkbox" />
|
||||
<span>Отображение</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label class="field-control">
|
||||
<span>ID блока</span>
|
||||
<input id="block-id" name="id" type="text" autocomplete="off" />
|
||||
</label>
|
||||
<label class="field-control">
|
||||
<span>Название в админке</span>
|
||||
<input id="block-label" name="adminLabel" type="text" autocomplete="off" />
|
||||
</label>
|
||||
<label class="field-control">
|
||||
<span>Шаблон / тип</span>
|
||||
<input id="block-template" name="template" type="text" autocomplete="off" readonly />
|
||||
</label>
|
||||
<label class="field-control">
|
||||
<span>Target</span>
|
||||
<input id="block-anchor" name="target" type="text" autocomplete="off" list="block-target-options" />
|
||||
<datalist id="block-target-options"></datalist>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card structure-card">
|
||||
<div class="card-head structure-head">
|
||||
<div>
|
||||
<div class="section-kicker">Порядок блока</div>
|
||||
<h3>Операции с блоком</h3>
|
||||
</div>
|
||||
<div class="button-row icon-action-row">
|
||||
<button id="move-up" class="icon-action-btn" type="button" title="Поднять блок" aria-label="Поднять блок">
|
||||
<span aria-hidden="true">↑</span>
|
||||
</button>
|
||||
<button id="move-down" class="icon-action-btn" type="button" title="Опустить блок" aria-label="Опустить блок">
|
||||
<span aria-hidden="true">↓</span>
|
||||
</button>
|
||||
<button id="duplicate" class="icon-action-btn" type="button" title="Дублировать блок" aria-label="Дублировать блок">
|
||||
<span aria-hidden="true">⧉</span>
|
||||
</button>
|
||||
<button id="copy" class="icon-action-btn operation-extra" type="button" title="Копировать блок" aria-label="Копировать блок">
|
||||
<span aria-hidden="true">□</span>
|
||||
</button>
|
||||
<button id="paste-after" class="icon-action-btn operation-extra" type="button" title="Вставить после" aria-label="Вставить после">
|
||||
<span aria-hidden="true">▣</span>
|
||||
</button>
|
||||
<button id="delete-block" class="icon-action-btn danger-action-btn" type="button" title="Удалить блок" aria-label="Удалить блок">
|
||||
<svg class="trash-can-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M3.75 6.75h16.5" />
|
||||
<path d="M8.25 6.75V4.9c0-.64.51-1.15 1.15-1.15h5.2c.64 0 1.15.51 1.15 1.15v1.85" />
|
||||
<path d="M6.25 6.75l.7 12.15c.06.78.7 1.35 1.47 1.35h7.16c.77 0 1.41-.57 1.47-1.35l.7-12.15" />
|
||||
<path d="M10 10.75v5.5" />
|
||||
<path d="M14 10.75v5.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="content-panel-kicker" class="section-kicker">Контент</div>
|
||||
<h3 id="content-panel-title">Редактируемые поля</h3>
|
||||
</div>
|
||||
<span id="content-panel-note" class="panel-note">Поля сгруппированы по смысловым подблокам верстки.</span>
|
||||
</div>
|
||||
<div id="content-fields" class="content-fields"></div>
|
||||
</section>
|
||||
|
||||
<details class="json-details">
|
||||
<summary>JSON выбранного блока</summary>
|
||||
<label class="json-field">
|
||||
<span>Техническое представление</span>
|
||||
<textarea id="block-json" spellcheck="false"></textarea>
|
||||
</label>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<div id="knowledge-form" class="knowledge-editor hidden"></div>
|
||||
|
||||
<pre id="status" class="status"></pre>
|
||||
</section>
|
||||
</main>
|
||||
<div id="template-modal" class="template-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="template-modal-title">
|
||||
<article class="template-modal">
|
||||
<header class="template-modal-head">
|
||||
<div>
|
||||
<div class="section-kicker">Библиотека блоков</div>
|
||||
<h2 id="template-modal-title">Добавить блок</h2>
|
||||
<p>Выберите шаблон. Новый блок появится в конце соответствующего списка.</p>
|
||||
</div>
|
||||
<button id="template-modal-close" class="admin-panel-close modal-close-button" type="button" aria-label="Закрыть">×</button>
|
||||
</header>
|
||||
<div id="template-list" class="template-list"></div>
|
||||
<footer class="template-modal-foot">
|
||||
<button id="template-modal-cancel" class="ghost-btn" type="button">Отмена</button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<div id="delete-modal" class="template-modal-layer delete-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="delete-modal-title" aria-describedby="delete-modal-body">
|
||||
<article class="template-modal delete-modal">
|
||||
<header class="template-modal-head delete-modal-head">
|
||||
<div>
|
||||
<div class="section-kicker">Удаление</div>
|
||||
<h2 id="delete-modal-title">Подтвердите действие</h2>
|
||||
<p id="delete-modal-body">Вы действительно подтвердить удаление элемента?</p>
|
||||
</div>
|
||||
</header>
|
||||
<footer class="template-modal-foot delete-modal-foot">
|
||||
<button id="delete-modal-cancel" class="ghost-btn" type="button">Отмена</button>
|
||||
<button id="delete-modal-confirm" class="danger-btn delete-confirm-btn" type="button">Да</button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<div id="media-modal" class="template-modal-layer media-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="media-modal-title">
|
||||
<article class="template-modal media-modal">
|
||||
<header class="template-modal-head media-modal-head">
|
||||
<div>
|
||||
<div class="section-kicker">Медиатека</div>
|
||||
<h2 id="media-modal-title">Выбрать файл</h2>
|
||||
<p id="media-modal-subtitle">Существующие файлы на сервере и загрузки для текущего поля.</p>
|
||||
</div>
|
||||
<div class="media-modal-actions">
|
||||
<span id="media-site-size" class="media-site-size">Сайт: считаем...</span>
|
||||
<button id="media-trash-toggle" class="admin-panel-close media-header-action media-trash-toggle" type="button" aria-label="Недавно удалённые" title="Недавно удалённые">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3.75 6.75h16.5" />
|
||||
<path d="M8.25 6.75V4.9c0-.64.51-1.15 1.15-1.15h5.2c.64 0 1.15.51 1.15 1.15v1.85" />
|
||||
<path d="M6.25 6.75l.7 12.15c.06.78.7 1.35 1.47 1.35h7.16c.77 0 1.41-.57 1.47-1.35l.7-12.15" />
|
||||
<path d="M10 10.75v5.5" />
|
||||
<path d="M14 10.75v5.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button id="media-modal-close" class="admin-panel-close modal-close-button media-header-action" type="button" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="media-toolbar">
|
||||
<label class="media-search">
|
||||
<span>Поиск</span>
|
||||
<input id="media-search" type="search" autocomplete="off" placeholder="Имя, папка, путь" />
|
||||
</label>
|
||||
<div id="media-kind-filters" class="media-filter-row media-kind-row" aria-label="Тип файла"></div>
|
||||
<div id="media-root-filters" class="media-filter-row media-root-row" aria-label="Корень файлов"></div>
|
||||
<button id="media-upload" class="primary-btn media-upload-btn" type="button">+ Загрузить</button>
|
||||
</div>
|
||||
<div id="media-drop-zone" class="media-browser media-browser-columns">
|
||||
<section class="media-main">
|
||||
<div id="media-breadcrumbs" class="media-breadcrumbs"></div>
|
||||
<div id="media-list" class="media-columns"></div>
|
||||
<div id="media-empty" class="media-empty hidden">Папка пуста.</div>
|
||||
</section>
|
||||
<aside class="media-preview-panel">
|
||||
<div id="media-preview" class="media-preview">
|
||||
<span>FILE</span>
|
||||
</div>
|
||||
<div class="media-preview-copy">
|
||||
<div id="media-preview-name" class="media-preview-name">Выберите файл</div>
|
||||
<div id="media-preview-path" class="media-preview-path"></div>
|
||||
<div id="media-preview-meta" class="media-preview-meta"></div>
|
||||
</div>
|
||||
<div id="media-preview-usage" class="media-usage-pill" data-status="idle">
|
||||
<span class="media-usage-dot"></span>
|
||||
<span>Нет выбора</span>
|
||||
</div>
|
||||
<div class="media-preview-actions">
|
||||
<button id="media-select" class="primary-btn" type="button" disabled>Выбрать</button>
|
||||
<button id="media-delete" class="danger-btn media-delete-btn" type="button" aria-label="Удалить файл" title="Удалить файл" disabled>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3.75 6.75h16.5" />
|
||||
<path d="M8.25 6.75V4.9c0-.64.51-1.15 1.15-1.15h5.2c.64 0 1.15.51 1.15 1.15v1.85" />
|
||||
<path d="M6.25 6.75l.7 12.15c.06.78.7 1.35 1.47 1.35h7.16c.77 0 1.41-.57 1.47-1.35l.7-12.15" />
|
||||
<path d="M10 10.75v5.5" />
|
||||
<path d="M14 10.75v5.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<footer class="template-modal-foot media-modal-foot">
|
||||
<button id="media-refresh" class="ghost-btn" type="button">Обновить список</button>
|
||||
<button id="media-modal-cancel" class="ghost-btn" type="button">Отмена</button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<div id="media-context-menu" class="media-context-menu hidden" role="menu"></div>
|
||||
<div id="media-action-modal" class="template-modal-layer media-action-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="media-action-title">
|
||||
<article class="template-modal media-action-modal">
|
||||
<header class="template-modal-head media-action-head">
|
||||
<div>
|
||||
<div class="section-kicker">Файл-менеджер</div>
|
||||
<h2 id="media-action-title">Действие</h2>
|
||||
<p id="media-action-body">Введите имя.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="media-action-body">
|
||||
<label class="field-control">
|
||||
<span id="media-action-label">Имя</span>
|
||||
<input id="media-action-input" type="text" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<footer class="template-modal-foot media-action-foot">
|
||||
<button id="media-action-cancel" class="ghost-btn" type="button">Отмена</button>
|
||||
<button id="media-action-confirm" class="primary-btn" type="button">Подтвердить</button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<div id="knowledge-button-modal" class="template-modal-layer knowledge-button-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="knowledge-button-title">
|
||||
<article class="template-modal knowledge-button-modal">
|
||||
<header class="template-modal-head knowledge-button-head">
|
||||
<div>
|
||||
<div class="section-kicker">База знаний</div>
|
||||
<h2 id="knowledge-button-title">Кнопка в статье</h2>
|
||||
<p>Ссылка ведёт на лендинг или внешний адрес. Target открывает нужный блок лендинга.</p>
|
||||
</div>
|
||||
<button id="knowledge-button-close" class="admin-panel-close modal-close-button" type="button" aria-label="Закрыть">×</button>
|
||||
</header>
|
||||
<div class="knowledge-button-body">
|
||||
<label class="field-control">
|
||||
<span>Название</span>
|
||||
<input id="knowledge-button-name" type="text" autocomplete="off" placeholder="Посмотреть демо" />
|
||||
</label>
|
||||
<label class="field-control">
|
||||
<span>Ссылка</span>
|
||||
<input id="knowledge-button-link" type="text" autocomplete="off" placeholder="/" />
|
||||
</label>
|
||||
<label class="field-control">
|
||||
<span>Target / блок лендинга</span>
|
||||
<input id="knowledge-button-target" type="text" autocomplete="off" list="knowledge-button-target-options" placeholder="hub-video" />
|
||||
<datalist id="knowledge-button-target-options"></datalist>
|
||||
</label>
|
||||
<p class="knowledge-button-hint">Можно выбрать target из списка или ввести ID блока лендинга. Если target пустой, ссылка откроет первый экран.</p>
|
||||
<p id="knowledge-button-error" class="knowledge-button-error" aria-live="polite"></p>
|
||||
</div>
|
||||
<footer class="template-modal-foot knowledge-button-foot">
|
||||
<button id="knowledge-button-cancel" class="ghost-btn" type="button">Отмена</button>
|
||||
<button id="knowledge-button-confirm" class="primary-btn" type="button">Добавить кнопку</button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<script src="./admin.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1 +0,0 @@
|
|||
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
|
@ -6,3 +6,4 @@
|
|||
[data-module=notification-w][data-notification-stack-ready]{height:auto;max-height:calc(100svh - 3.2rem);overflow:visible;scrollbar-width:none;overscroll-behavior:contain}[data-module=notification-w][data-notification-stack-ready]::-webkit-scrollbar{display:none}[data-module=notification-w][data-notification-stack-ready]:not(:hover):not([data-notification-expanded=true]){height:var(--notification-collapsed-height,6.6rem);max-height:var(--notification-collapsed-height,6.6rem)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true]){overflow-x:visible;overflow-y:auto;padding-bottom:.2rem}[data-module=notification-w][data-notification-stack-ready]>[data-module=notification]{position:relative;z-index:var(--notification-z,1);transform:translateY(var(--notification-stack-y,0));transition:transform .3s cubic-bezier(.22,1,.36,1)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true])>[data-module=notification]{transform:translateY(0)}
|
||||
.dock-icon.nodedc-app{overflow:hidden;background-color:transparent;background-image:none}.dock-icon.nodedc-app>img{display:block;width:100%;height:100%;max-width:none;object-fit:cover;border-radius:inherit}
|
||||
[data-module=dock]{overflow:visible}[data-module=dock] .dock-icon{position:relative;overflow:visible}[data-module=dock] .dock-icon.nodedc-app{overflow:visible}[data-module=dock] .dock-icon::after{content:attr(data-dock-title);position:absolute;z-index:30;left:50%;bottom:calc(100% + .64rem);max-width:14rem;transform:translate(-50%,.28rem) scale(.96);transform-origin:50% 100%;opacity:0;pointer-events:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:1px solid rgba(255,255,255,.12);border-radius:.72rem;background:linear-gradient(180deg,rgba(255,255,255,.07),rgba(255,255,255,.018)),rgba(34,34,38,.86);box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 20px 60px rgba(0,0,0,.42);-webkit-backdrop-filter:blur(22px) saturate(1.15);backdrop-filter:blur(22px) saturate(1.15);color:rgba(255,255,255,.92);padding:.42rem .68rem;font-size:.68rem;font-weight:760;line-height:1;letter-spacing:0;transition:opacity .16s ease,transform .16s ease}[data-module=dock] .dock-icon:is(:hover,:focus-visible){z-index:20}[data-module=dock] .dock-icon:is(:hover,:focus-visible)::after{opacity:1;transform:translate(-50%,0) scale(1)}[data-module=dock] .dock-icon[data-dock-title=""]::after{display:none}
|
||||
@media screen and (max-width:767px){.c-viz{content-visibility:visible}}
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@
|
|||
[data-module=notification-w][data-notification-stack-ready]{height:auto;max-height:calc(100svh - 3.2rem);overflow:visible;scrollbar-width:none;overscroll-behavior:contain}[data-module=notification-w][data-notification-stack-ready]::-webkit-scrollbar{display:none}[data-module=notification-w][data-notification-stack-ready]:not(:hover):not([data-notification-expanded=true]){height:var(--notification-collapsed-height,6.6rem);max-height:var(--notification-collapsed-height,6.6rem)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true]){overflow-x:visible;overflow-y:auto;padding-bottom:.2rem}[data-module=notification-w][data-notification-stack-ready]>[data-module=notification]{position:relative;z-index:var(--notification-z,1);transform:translateY(var(--notification-stack-y,0));transition:transform .3s cubic-bezier(.22,1,.36,1)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true])>[data-module=notification]{transform:translateY(0)}
|
||||
.dock-icon.nodedc-app{overflow:hidden;background-color:transparent;background-image:none}.dock-icon.nodedc-app>img{display:block;width:100%;height:100%;max-width:none;object-fit:cover;border-radius:inherit}
|
||||
[data-module=dock]{overflow:visible}[data-module=dock] .dock-icon{position:relative;overflow:visible}[data-module=dock] .dock-icon.nodedc-app{overflow:visible}[data-module=dock] .dock-icon::after{content:attr(data-dock-title);position:absolute;z-index:30;left:50%;bottom:calc(100% + .64rem);max-width:14rem;transform:translate(-50%,.28rem) scale(.96);transform-origin:50% 100%;opacity:0;pointer-events:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:1px solid rgba(255,255,255,.12);border-radius:.72rem;background:linear-gradient(180deg,rgba(255,255,255,.07),rgba(255,255,255,.018)),rgba(34,34,38,.86);box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 20px 60px rgba(0,0,0,.42);-webkit-backdrop-filter:blur(22px) saturate(1.15);backdrop-filter:blur(22px) saturate(1.15);color:rgba(255,255,255,.92);padding:.42rem .68rem;font-size:.68rem;font-weight:760;line-height:1;letter-spacing:0;transition:opacity .16s ease,transform .16s ease}[data-module=dock] .dock-icon:is(:hover,:focus-visible){z-index:20}[data-module=dock] .dock-icon:is(:hover,:focus-visible)::after{opacity:1;transform:translate(-50%,0) scale(1)}[data-module=dock] .dock-icon[data-dock-title=""]::after{display:none}
|
||||
@media screen and (max-width:767px){.c-viz{content-visibility:visible}}
|
||||
|
|
|
|||
|
|
@ -12,17 +12,21 @@
|
|||
const MEDIA_HANDLE_BOUND_FLAG = "__nodedcMediaHandleBound";
|
||||
const DOCK_LINK_BOUND_FLAG = "__nodedcDockLinkBound";
|
||||
const ORDERED_SLIDER_BOUND_FLAG = "__nodedcOrderedSliderBound";
|
||||
const SLIDER_TOUCH_GUARD_FLAG = "__nodedcSliderTouchGuardBound";
|
||||
const LAZY_MEDIA_VIDEO_BOUND_FLAG = "__nodedcLazyMediaVideoBound";
|
||||
const SCROLL_HEIGHT_SYNC_FLAG = "__nodedcScrollHeightSyncBound";
|
||||
const NOTIFICATION_STACK_BOUND_FLAG = "__nodedcNotificationStackBound";
|
||||
const SCROLL_HEIGHT_SYNC_WARMUP_MS = 14000;
|
||||
const SCROLL_HEIGHT_SYNC_INTERVAL_MS = 90;
|
||||
const SLIDER_TOUCH_INTENT_DISTANCE = 8;
|
||||
const SLIDER_HORIZONTAL_INTENT_RATIO = 1.35;
|
||||
const IMAGE_EXTENSIONS = new Set(["avif", "gif", "jpeg", "jpg", "png", "svg", "webp"]);
|
||||
const VIDEO_EXTENSIONS = new Set(["m4v", "mov", "mp4", "webm"]);
|
||||
let lastDocumentScrollHeight = 0;
|
||||
let scrollHeightSyncFrame = 0;
|
||||
let scrollHeightSyncShouldForce = false;
|
||||
let scrollHeightDeferredSync = 0;
|
||||
let activeSliderTouch = null;
|
||||
|
||||
function isDesktopPointer() {
|
||||
return window.matchMedia?.("(pointer: fine)").matches ?? true;
|
||||
|
|
@ -333,6 +337,61 @@
|
|||
document.querySelectorAll('[data-module="mail-list"]').forEach(bindMailListWheel);
|
||||
}
|
||||
|
||||
function bindSliderTouchGuard() {
|
||||
if (document[SLIDER_TOUCH_GUARD_FLAG]) return;
|
||||
document[SLIDER_TOUCH_GUARD_FLAG] = true;
|
||||
|
||||
window.addEventListener(
|
||||
"touchstart",
|
||||
(event) => {
|
||||
const slider = event.target?.closest?.('[data-module="slider"]');
|
||||
const touch = event.touches?.[0];
|
||||
activeSliderTouch = slider && touch
|
||||
? {
|
||||
identifier: touch.identifier,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
intent: "",
|
||||
}
|
||||
: null;
|
||||
},
|
||||
{ capture: true, passive: true },
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"touchmove",
|
||||
(event) => {
|
||||
if (!activeSliderTouch) return;
|
||||
const touch = Array.from(event.touches || []).find(
|
||||
(candidate) => candidate.identifier === activeSliderTouch.identifier,
|
||||
);
|
||||
if (!touch) return;
|
||||
|
||||
const deltaX = Math.abs(touch.clientX - activeSliderTouch.startX);
|
||||
const deltaY = Math.abs(touch.clientY - activeSliderTouch.startY);
|
||||
|
||||
if (!activeSliderTouch.intent) {
|
||||
if (Math.max(deltaX, deltaY) < SLIDER_TOUCH_INTENT_DISTANCE) {
|
||||
event.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
activeSliderTouch.intent = deltaX > deltaY * SLIDER_HORIZONTAL_INTENT_RATIO ? "horizontal" : "vertical";
|
||||
}
|
||||
|
||||
if (activeSliderTouch.intent === "vertical") {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: false },
|
||||
);
|
||||
|
||||
const clearTouch = () => {
|
||||
activeSliderTouch = null;
|
||||
};
|
||||
window.addEventListener("touchend", clearTouch, { capture: true, passive: true });
|
||||
window.addEventListener("touchcancel", clearTouch, { capture: true, passive: true });
|
||||
}
|
||||
|
||||
function bindOrderedSlider(surface) {
|
||||
if (!surface || surface[ORDERED_SLIDER_BOUND_FLAG]) return;
|
||||
|
||||
|
|
@ -582,6 +641,7 @@
|
|||
}
|
||||
|
||||
bindScrollHeightSync();
|
||||
bindSliderTouchGuard();
|
||||
hydrateMediaHandles();
|
||||
bindNotificationStacks();
|
||||
|
||||
|
|
@ -590,6 +650,7 @@
|
|||
"DOMContentLoaded",
|
||||
() => {
|
||||
bindScrollHeightSync();
|
||||
bindSliderTouchGuard();
|
||||
hydrateMediaHandles();
|
||||
bindInteractiveScroll();
|
||||
bindOrderedSliders();
|
||||
|
|
@ -603,6 +664,7 @@
|
|||
);
|
||||
} else {
|
||||
bindScrollHeightSync();
|
||||
bindSliderTouchGuard();
|
||||
hydrateMediaHandles();
|
||||
bindInteractiveScroll();
|
||||
bindOrderedSliders();
|
||||
|
|
@ -615,6 +677,7 @@
|
|||
|
||||
window.addEventListener("load", () => {
|
||||
bindScrollHeightSync();
|
||||
bindSliderTouchGuard();
|
||||
hydrateMediaHandles();
|
||||
bindInteractiveScroll();
|
||||
bindOrderedSliders();
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +0,0 @@
|
|||
# NODE.DC Admin Notes
|
||||
|
||||
The current admin flow edits the structured homepage model and renders `index.html` with `npm run render:home`.
|
||||
|
||||
Sanitary rule: admin presets and generated public pages must not contain legacy product names, old external documentation links, or old API origins. Any new SEO/content fields should use the NODE.DC ontology only.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,6 @@
|
|||
"scripts": {
|
||||
"extract:home": "node tools/extract-home-blocks.mjs",
|
||||
"render:home": "node tools/render-home.mjs",
|
||||
"render:knowledge": "node tools/render-knowledge.mjs",
|
||||
"admin": "node tools/admin-server.mjs"
|
||||
"render:knowledge": "node tools/render-knowledge.mjs"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
sitemap.xml
64
sitemap.xml
|
|
@ -8,7 +8,7 @@
|
|||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/platform/</loc>
|
||||
|
|
@ -18,23 +18,43 @@
|
|||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/platform/hub/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/platform/engine/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/platform/ops/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/platform/integration-layer/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/platform/artifact-event-layer/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/agent-context/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/agent-context/document-agent/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/agent-context/agent-catalog/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
|
|
@ -43,41 +63,61 @@
|
|||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/tender-agent/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/1c-integration/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/drone-management/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/city-digital-twins/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/industrial-contour/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/enterprise-management/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/construction-contour/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/seo-module/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/procurement-contour/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/project-office/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/video-analytics/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nodedc.dctouch.ru/knowledge/modules/engineering-contour/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,262 @@
|
|||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const knowledgePath = resolve(repoRoot, "content", "knowledge.json");
|
||||
const args = process.argv.slice(2);
|
||||
const inputPath = args.find((value) => !value.startsWith("--"));
|
||||
const dryRun = args.includes("--dry-run");
|
||||
|
||||
if (!inputPath) {
|
||||
throw new Error("Укажите путь к NODEDC_Knowledge_SEO_replacement_pack.md");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function cleanMetaValue(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
return trimmed.startsWith("`") && trimmed.endsWith("`") ? trimmed.slice(1, -1).trim() : trimmed;
|
||||
}
|
||||
|
||||
function metadataValue(section, label) {
|
||||
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = section.match(new RegExp(`^\\*\\*${escapedLabel}:\\*\\*\\s*(.+?)\\s*$`, "m"));
|
||||
return cleanMetaValue(match?.[1] || "");
|
||||
}
|
||||
|
||||
function inlineMarkdown(value) {
|
||||
return escapeHtml(String(value || "").trim())
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
function buttonShortcode(label, href) {
|
||||
const name = String(label || "Открыть").replaceAll('"', """);
|
||||
const link = String(href || "/").replaceAll('"', """);
|
||||
return `<p>[button name="${name}" link="${link}"]</p>`;
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown) {
|
||||
const lines = String(markdown || "").replaceAll("\r\n", "\n").split("\n");
|
||||
const blocks = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index].trim();
|
||||
if (!line || line === "---") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^#\s+/.test(line)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = /^(#{2,3})\s+(.+)$/.exec(line);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
blocks.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(line);
|
||||
if (link) {
|
||||
blocks.push(buttonShortcode(link[1], link[2]));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = /^[-*]\s+(.+)$/.exec(line);
|
||||
if (bullet) {
|
||||
const items = [];
|
||||
while (index < lines.length) {
|
||||
const item = /^[-*]\s+(.+)$/.exec(lines[index].trim());
|
||||
if (!item) break;
|
||||
items.push(`<li>${inlineMarkdown(item[1])}</li>`);
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(`<ul>${items.join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ordered = /^\d+\.\s+(.+)$/.exec(line);
|
||||
if (ordered) {
|
||||
const items = [];
|
||||
while (index < lines.length) {
|
||||
const item = /^\d+\.\s+(.+)$/.exec(lines[index].trim());
|
||||
if (!item) break;
|
||||
items.push(`<li>${inlineMarkdown(item[1])}</li>`);
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(`<ol>${items.join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraph = [];
|
||||
while (index < lines.length) {
|
||||
const next = lines[index].trim();
|
||||
if (!next || next === "---" || /^#{1,3}\s+/.test(next) || /^[-*]\s+/.test(next) || /^\d+\.\s+/.test(next) || /^\[[^\]]+\]\([^)\s]+\)$/.test(next)) {
|
||||
break;
|
||||
}
|
||||
paragraph.push(next);
|
||||
index += 1;
|
||||
}
|
||||
if (paragraph.length) {
|
||||
blocks.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return `${blocks.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function parsePack(markdown) {
|
||||
const matches = [...markdown.matchAll(/^## (\d+)\. (.+)$/gm)];
|
||||
const pages = new Map();
|
||||
|
||||
for (let index = 0; index < matches.length; index += 1) {
|
||||
const match = matches[index];
|
||||
const number = Number(match[1]);
|
||||
if (!Number.isInteger(number) || number < 1 || number > 23) continue;
|
||||
|
||||
const section = markdown.slice(match.index + match[0].length, matches[index + 1]?.index || markdown.length);
|
||||
const h1 = /^# (.+)$/m.exec(section);
|
||||
if (!h1) throw new Error(`В SEO-паке не найден H1 для раздела ${number}`);
|
||||
|
||||
const bodyStart = (h1.index || 0) + h1[0].length;
|
||||
const page = {
|
||||
number,
|
||||
title: h1[1].trim(),
|
||||
id: metadataValue(section, "ID"),
|
||||
slug: metadataValue(section, "Slug"),
|
||||
seoTitle: metadataValue(section, "SEO title"),
|
||||
seoDescription: metadataValue(section, "SEO description"),
|
||||
bodyHtml: markdownToHtml(section.slice(bodyStart)),
|
||||
};
|
||||
|
||||
if (number > 1 && (!page.id || !page.slug || !page.seoTitle || !page.seoDescription)) {
|
||||
throw new Error(`В SEO-паке неполные метаданные для «${page.title}»`);
|
||||
}
|
||||
pages.set(number, page);
|
||||
}
|
||||
|
||||
if (pages.size !== 23 || !pages.has(1)) {
|
||||
throw new Error(`Ожидалось 23 страницы SEO-пака, найдено ${pages.size}`);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
function walk(nodes, callback) {
|
||||
for (const node of nodes || []) {
|
||||
callback(node);
|
||||
walk(node.children, callback);
|
||||
}
|
||||
}
|
||||
|
||||
function leadingMediaParagraph(html) {
|
||||
return String(html || "").match(/^\s*(<p>\s*<(?:img|video)\b[\s\S]*?<\/p>)/i)?.[1] || "";
|
||||
}
|
||||
|
||||
function materialFromPage(page, existing) {
|
||||
const media = leadingMediaParagraph(existing?.bodyHtml);
|
||||
return {
|
||||
...(existing || {}),
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
enabled: true,
|
||||
useContent: true,
|
||||
seo: {
|
||||
title: page.seoTitle,
|
||||
description: page.seoDescription,
|
||||
},
|
||||
bodyHtml: `${media ? `${media}\n` : ""}${page.bodyHtml}`,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
const treeOrder = [
|
||||
{
|
||||
id: "platform-overview",
|
||||
children: ["hub", "engine", "ops", "integration-layer", "artifact-event-layer"],
|
||||
},
|
||||
{
|
||||
id: "agent-context",
|
||||
children: ["document-agent", "agent-catalog"],
|
||||
},
|
||||
{
|
||||
id: "applied-modules",
|
||||
children: [
|
||||
"tender-agent",
|
||||
"1c-integration",
|
||||
"drone-management",
|
||||
"city-digital-twins",
|
||||
"industrial-contour",
|
||||
"enterprise-management",
|
||||
"construction-contour",
|
||||
"seo-module",
|
||||
"procurement-contour",
|
||||
"project-office",
|
||||
"video-analytics",
|
||||
"engineering-contour",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const [markdown, rawKnowledge] = await Promise.all([
|
||||
readFile(resolve(inputPath), "utf8"),
|
||||
readFile(knowledgePath, "utf8"),
|
||||
]);
|
||||
const pages = parsePack(markdown);
|
||||
const knowledge = JSON.parse(rawKnowledge);
|
||||
const existingNodes = new Map();
|
||||
walk(knowledge.tree, (node) => existingNodes.set(node.id, node));
|
||||
|
||||
const rootPage = pages.get(1);
|
||||
const pageById = new Map([...pages.values()].filter((page) => page.number > 1).map((page) => [page.id, page]));
|
||||
const materials = new Map([...pageById].map(([id, page]) => [id, materialFromPage(page, existingNodes.get(id))]));
|
||||
|
||||
for (const { id, children } of treeOrder) {
|
||||
const node = materials.get(id);
|
||||
if (!node) throw new Error(`Не найден раздел «${id}» в SEO-паке`);
|
||||
node.children = children.map((childId) => {
|
||||
const child = materials.get(childId);
|
||||
if (!child) throw new Error(`Не найден дочерний раздел «${childId}» в SEO-паке`);
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
knowledge.title = rootPage.title;
|
||||
knowledge.seo = {
|
||||
...(knowledge.seo || {}),
|
||||
title: rootPage.seoTitle,
|
||||
description: rootPage.seoDescription,
|
||||
};
|
||||
knowledge.settings ||= {};
|
||||
knowledge.settings.description = rootPage.seoDescription;
|
||||
knowledge.introHtml = rootPage.bodyHtml;
|
||||
knowledge.tree = treeOrder.map(({ id }) => materials.get(id));
|
||||
|
||||
const summary = {
|
||||
pages: pages.size,
|
||||
updated: [...pageById.keys()].filter((id) => existingNodes.has(id)).length,
|
||||
added: [...pageById.keys()].filter((id) => !existingNodes.has(id)).length,
|
||||
tree: knowledge.tree.map((node) => ({ id: node.id, children: node.children.map((child) => child.id) })),
|
||||
};
|
||||
|
||||
if (!dryRun) {
|
||||
await writeFile(knowledgePath, `${JSON.stringify(knowledge, null, 2)}\n`);
|
||||
}
|
||||
|
||||
console.log(`${dryRun ? "Checked" : "Imported"} Knowledge SEO pack: ${JSON.stringify(summary)}`);
|
||||
|
|
@ -548,7 +548,7 @@ function renderHeader(searchPlaceholder) {
|
|||
}
|
||||
|
||||
function pageTitle(record = null) {
|
||||
if (!record) return knowledge.title || "База знаний";
|
||||
if (!record) return knowledge.seo?.title || knowledge.title || "База знаний";
|
||||
return record.node.seo?.title || record.node.title || "Материал";
|
||||
}
|
||||
|
||||
|
|
@ -558,7 +558,7 @@ function visibleTitle(record = null) {
|
|||
}
|
||||
|
||||
function pageDescription(record = null) {
|
||||
if (!record) return knowledge.settings?.description || "";
|
||||
if (!record) return knowledge.seo?.description || knowledge.settings?.description || "";
|
||||
return record.node.seo?.description || stripHtml(renderKnowledgeShortcodes(record.node.bodyHtml)).slice(0, 180);
|
||||
}
|
||||
|
||||
|
|
@ -723,8 +723,8 @@ async function writePage(urlPath, html) {
|
|||
async function writeSeoFiles(records) {
|
||||
const sitemapUrls = [
|
||||
{ loc: canonicalHome(), priority: "1.0" },
|
||||
{ loc: canonicalForPath(`/${knowledgeBasePath()}/`), priority: "0.8" },
|
||||
...records.map((record) => ({ loc: canonicalForPath(record.urlPath), priority: record.parents.length ? "0.6" : "0.7" })),
|
||||
{ loc: canonicalForPath(`/${knowledgeBasePath()}/`), priority: "0.7" },
|
||||
...records.map((record) => ({ loc: canonicalForPath(record.urlPath), priority: "0.7" })),
|
||||
];
|
||||
const sitemapUrl = new URL("sitemap.xml", canonicalHome()).href;
|
||||
const robots = ["User-agent: *", "Allow: /", `Sitemap: ${sitemapUrl}`, ""].join("\n");
|
||||
|
|
|
|||
Loading…
Reference in New Issue