Архитектура конструктора веб-сайтов
Каждый церковный веб-сайт, обслуживаемый B1App, рендерится из дерева контента — страницы, разделы, элементы — хранимого в ContentApi и редактируемого визуально в B1Admin. Одна общая библиотека компонентов рендерит и предпросмотр редактора, и живой сайт, один каталог типов элементов определяет, что может появиться на странице, а отдельный сервис ИИ может генерировать или переписывать это дерево. На этой странице описан весь стек: контракт элементов в @churchapps/helpers, конвейер рендеринга, элементы церковных данных, общесайтовые виджеты, слой блога, страницы с ограниченным доступом, SEO, генерация ИИ и разговорные формы.
Обзор
┌──────────────────────────────┐ ┌─────────────────────────────────────────┐
│ B1Admin — editor │ │ Api — /content module (ContentApi) │
│ ContentEditor · SectionEdit │ POST /… │ │
│ ElementEdit · PageLinkEdit │ ──────────▶ │ pages ─ sections ─ elements blocks │
│ SiteWidgetsEdit · Blog │ │ posts redirects settings styles │
└──────────┬───────────────────┘ └───────────────┬─────────────────────────┘
│ │ GET /content/pages/:churchId/tree?url=…
│ shared render pipeline ▼ (anon, JWT honored)
│ ┌───────────────────────────────┐ ┌─────────────────────────────────┐
└──▶│ @churchapps/helpers │◀──│ B1App — public site (Next.js) │
│ ElementTypes.ts (catalog) │ │ Zone → Section → Element │
│ @churchapps/apphelper │ │ + widgets, JSON-LD, sitemap, │
│ ElementRegistry, renderers │ │ redirects, branded 404 │
│ SectionDivider, widgets │ └───────────────┬─────────────────┘
└───────────────────────────────┘ │ church-data elements
┌──────────────────────────────┐ ▼
│ AskApi — /website/* (AI) │ ┌─────────────────────────────────────────┐
│ generateSite · rewriteSection│ │ /giving/funds/public/…/total │
│ generateAltText · metaDesc │ │ /membership/groupmembers/public/… │
│ returns JSON; B1Admin saves │ │ /attendance/servicetimes/public/… │
└──────────────────────────────┘ └─────────────────────────────────────────┘
Три правила действуют во всём стеке:
- Одно дерево, два рендерера. Страница — это дерево
pages → sections → elements, где каждый узел несёт свои настройки как JSON-блокanswers. Одни и те же компоненты apphelper рендерят и редактор drag-and-drop в B1Admin, и серверно-рендерённый публичный сайт в B1App — отдельного «формата публикации» не существует. - Контракт живёт в
@churchapps/helpers.ElementTypes.ts— единственный каталог типов элементов; рендереры разрешаются через реестр в apphelper; формы редактора живут в B1Admin. Добавление типа элемента означает изменения во всех трёх местах, именно в этом порядке. - Публичный сайт читает анонимные конечные точки. Всё, что нужно B1App — дерево страниц, настройки, посты блога, редиректы и конечные точки церковных данных в других модулях — публично. Аутентификация опциональна: JWT на анонимной конечной точке дерева открывает страницы только для участников, ничего больше не меняется.
Дерево контента
Модуль content (Api/src/modules/content) владеет данными конструктора:
| Таблица | Роль |
|---|---|
pages | Одна страница на URL: url, title, layout, плюс visibility/groupIds (ограничение доступа) и metaDescription (SEO) |
sections | Горизонтальные полосы на странице (или в блоке): фон, цвет текста и answersJSON, несущий стилизацию плюс конфигурации разделителей формы dividerTop/dividerBottom |
elements | Элементы контента внутри раздела: elementType + answersJSON, вложены для типов компоновки (строка/колонка, карусель) |
blocks | Переиспользуемые группы разделов/элементов (блоки подвала, блоки элементов), общие для страниц |
posts | Отдельные посты блога (см. Блог) |
redirects | Пары fromPath → toPath на церковь, ограничены 200 записями (см. SEO) |
settings | Настройки церкви в формате «ключ-значение»; строки, помеченные public, обслуживаются анонимно и несут конфигурацию виджетов/аналитики |
Всё дерево для одного URL приходит одним анонимным вызовом — GET /content/pages/:churchId/tree?url=/about — именно из него B1App выполняет серверный рендеринг. Запросы редактора получают данные по id и сохраняют внутренние id.
Контракт элементов
Каталог (@churchapps/helpers)
Packages/helpers/src/ElementTypes.ts определяет каждый тип элемента как ElementTypeDefinition: elementType, label, category, schemaVersion, defaults и JSON-schema-подобную answersSchema для его настроек. validateElementAnswers() намеренно снисходительна — неизвестные типы и лишние ключи проходят, поэтому старый контент никогда не ломается при обновлении каталога. Сегодня поставляется 35 типов:
| Категория | Типы элементов |
|---|---|
| компоновка (6) | row, column, box, carousel, whiteSpace, block |
| контент (11) | text, textWithPhoto, card, faq, iconFeature, testimonial, socialIcons, countdown, stats, table, buttonLink |
| медиа (4) | image, gallery, video, map |
| церковные (12) | logo, sermons, stream, donation, donateLink, form, calendar, groupList, groups, campaignProgress, staffGrid, serviceTimes |
| расширенные (2) | rawHTML, iframe |
Элемент sermons — самый настраиваемый из церковных типов: настройка layout выбирает browse (устаревший полный браузер), grid, list или featuredLatest, а playlistId, itemCount, showTitles и showDates уточняют не-browse раскладки.
Рендереры (@churchapps/apphelper)
Рендереры живут в Packages/apphelper/src/website/components/elementTypes/, по одному компоненту на тип, разрешаемые через ElementRegistry.ts — двухуровневую карту, где Element.tsx регистрирует рендерер по умолчанию для всех 35 типов (registerDefaultElementRenderer), а принимающее приложение может переопределить любой из них во время выполнения (registerElementRenderer) без форка пакета.
Формы редактора (B1Admin)
Формы настроек редактора для каждого типа живут в B1Admin/src/site/admin/elements/ — ElementEdit.tsx направляет к выделенному компоненту (GalleryEdit, TestimonialEdit, StatsEdit, …) либо к встроенному конструктору полей для каждого типа. Отражение этого каталога, ориентированное на ИИ, — инструмент MCP API describe_page_builder (см. MCP-сервер).
Разделители формы разделов
Разделы могут нести декоративные разделители формы на любом краю. Конфигурация живёт в answersJSON раздела как объекты dividerTop / dividerBottom — { shape, color, height, flip }, где shape — один из wave, waves, slant, curve, triangle, peaks. Apphelper поставляет компонент SectionDivider и вспомогательную функцию parseDividerConfig(); рендереры разделов обоих приложений (B1App/src/components/Section.tsx, B1Admin/src/site/admin/Section.tsx) разбирают настройки и монтируют разделитель, а SectionEdit.tsx в B1Admin предоставляет интерфейс выбора. Пакеты поставляют только строительный блок — привязка на уровне раздела — работа потребляющих приложений.
Элементы церковных данных
Три типа элементов рендерят живые церковные данные, а не авторский контент. Изоляция модулей по-прежнему применяется — каждый вызывает публичную конечную точку своего собственного владеющего модуля из браузера:
| Элемент | Конечная точка | Примечания |
|---|---|---|
campaignProgress | GET /giving/funds/public/:churchId/:fundId/total | Возвращает { fundId, totalAmount, donationCount }, опциональное окно ?startDate=&endDate=; элемент сравнивает это со своей настройкой goalAmount |
staffGrid | GET /membership/groupmembers/public/:churchId/:groupId | Только по подписке (opt-in): у группы должен быть установлен publicRoster (по умолчанию выключен). Проекция намеренно минимальна — personId, displayName, leader, фото — никаких контактных или демографических полей |
serviceTimes | GET /attendance/servicetimes/public/:churchId | Возвращает дерево кампус → служение → время; рендерер apphelper по возможности генерирует из него JSON-LD Event в формате schema.org (API возвращает обычные данные) |
publicRoster — это шлюз приватности для staffGrid. Никогда не расширяйте публичную проекцию членов группы и не обходите этот флаг — конечная точка списка участников анонимна по замыслу, и минимальный список полей — это её свойство безопасности.
Общесайтовые виджеты
Два виджета рендерятся на каждой публичной странице, а не внутри дерева: AnnouncementBanner (закрываемая полоса вверху страницы) и Launcher (плавающий центр действий для ссылок в стиле «пожертвовать/посетить/смотреть»). Оба компонента и их вспомогательные функции parse*Config() поставляются в apphelper. Конфигурация — это две публичные строки настроек — ключи announcementBanner и launcher — записываемые SiteWidgetsEdit B1Admin (на странице Appearance) и читаемые публичным макетом B1App через GET /content/settings/public/:churchId. API рассматривает их как непрозрачные пары «ключ-значение»; имена ключей — это соглашение между двумя приложениями.
Блог
Блог — это отдельный тип контента, а не слой поверх страниц конструктора. Строка posts хранит весь пост: title, slug, excerpt, content (тело в markdown), authorId, photoUrl, publishDate, category, tags. Публичная поверхность (всё анонимно, PostController):
| Маршрут | Назначение |
|---|---|
GET /content/posts/public/:churchId | Опубликованные посты, фильтруемые по ?category=&tag=, с пагинацией |
GET /content/posts/public/:churchId/categories | Отдельные категории среди опубликованных постов |
GET /content/posts/public/:churchId/slug/:slug | Один опубликованный пост |
GET /content/posts/rss/:churchId?siteUrl= | RSS 2.0-фид, озаглавленный именем церкви, с категорией и описанием (выдержкой или контентом) для каждого элемента |
Пост считается «опубликованным», как только установлена и прошла дата publishDate; будущая publishDate означает запланированный пост (скрыт публично, показывается с чипом «Запланировано» в администрировании). Конечные точки чтения обогащают каждый пост полем authorName, разрешаемым из authorId через шлюз модуля членства. Отсутствующие выдержки заменяются урезанным markdown-контентом (~160 символов) в карточках списка, метаописаниях и RSS. B1App обслуживает /{sdSlug}/blog — редакционный список (центрированный заголовок, становящийся именем активной категории/тега при фильтрации, строка фильтра по категориям-чипам, строки постов с миниатюрой слева, подписями авторов и выдержками) с RSS-фидом, объявленным как альтернативная ссылка — и /{sdSlug}/blog/[postSlug], отдельный маршрут (не конвейер Zone/Section) с центрированным заголовком (категория-плашка, заголовок, подпись автора, акцентная линия основного цвета), героем 16:9 по ширине контейнера, телом markdown в колонке чтения ~720px, чипами тегов в подвале статьи, полосой связанных постов «Больше в {category}» и JSON-LD BlogPosting, включающим автора. Обе страницы полностью стилизуются из токенов темы, поэтому наследуют палитру каждой церкви. URL блога включены в карту сайта для каждой церкви. Интерфейс написания B1Admin (Site → Blog) редактирует посты в диалоге: markdown-редактор с переключателем предпросмотра, выбор изображения галереи с обрезкой 16:9, выбор автора-человека (по умолчанию редактирующий пользователь), автодополнение категории, заполненное существующими категориями, проверка на дублирующийся slug и переключатель публикации; опубликованные строки ссылаются на живой пост, а страница подталкивает администраторов добавить навигационную ссылку /blog.
Страницы только для участников
pages.visibility переиспользует перечисление навигационных ссылок — everyone (по умолчанию), visitors, members, staff, team, groups (с groupIds) — но как жёсткий шлюз доступа, а не фильтр навигации (PageVisibilityHelper.canViewPage). Поток:
- Анонимная конечная точка дерева проверяет видимость при запросах по URL. Анонимные вызывающие стороны для ограниченной страницы получают
{ restricted: true, visibility }вместо контента — дерево никогда не утекает. - Конечная точка по-прежнему учитывает JWT:
CustomAuthProviderпроверяет заголовокAuthorizationпри каждом запросе, включая анонимные маршруты, так что запрос авторизованного участника к тому же URL разрешается нормально. - B1App рендерит
RestrictedPageпри ответеrestricted: он восстанавливает сессию из сохранённых учётных данных, заново запрашивает дерево с JWT и рендерит его — либо показывает шлюз входа сreturnUrl, когда сессии нет.
Детализация шлюза различается по уровням: groups проверяет groupIds токена против списка страницы, а staff проверяет membershipStatus, но members и team в текущей реализации пропускают любого авторизованного пользователя церкви. Считайте groups строгим вариантом.
SEO и обнаруживаемость
Всё это — рендеринг на стороне B1App поверх данных ContentApi — API хранит, приложение выдаёт:
| Аспект | Как это работает |
|---|---|
| Метаописания | pages.metaDescription (≤300 символов) проходит через MetaHelper.getMetaData() в Metadata Next.js (описание + Open Graph) на каждом маршруте, рендерящемся конструктором. Настройки страницы B1Admin включают кнопку ИИ «Generate» (см. ниже) |
| Редиректы | Строки redirects для каждой церкви, управляемые на /content/redirects (content.edit, лимит 200 строк, нормализованные пути). При потенциальной 404 маршрут страницы B1App разрешает путь против GET /content/redirects/public/:churchId и выдаёт HTTP 308 через permanentRedirect из Next; несовпавшие пути проваливаются к notFound() |
| Брендированная 404 | not-found.tsx рендерит BrandedNotFound с логотипом церкви, именем и темой вместо обобщённой ошибки |
| Структурированные данные | JSON-LD BlogPosting на постах блога; VideoObject на страницах отдельных проповедей (/{sdSlug}/sermons/[sermonId]) и на страницах, содержащих элемент sermons; Event из элементов календаря/мероприятия на страницах конструктора; schema.org Event из элемента serviceTimes |
| Страницы проповедей | Каждая публичная проповедь получает индексируемую страницу на /sermons/[sermonId] с полными метаданными — проповеди больше не заперты внутри клиентского элемента-браузера |
| Аналитика | Публичный ключ настроек ga4MeasurementId (управляется рядом с редиректами в B1Admin) внедряет тег GA4 gtag для каждой церкви через next/script |
| Карта сайта и фиды | Маршрут sitemap.xml для каждой церкви включает страницы конструктора и URL блога; список блога объявляет RSS-фид |
| Доступность | Публичное окружение рендерит ссылку пропуска, ведущую к ориентиру <main id="main-content"> в каждой обёртке макета |
Генерация ИИ (AskApi)
Генерация страниц и сайтов выполняется в AskApi, отдельном сервисе, под контроллером /website. Он аутентифицируется тем же JWT CustomAuthProvider, что и всё остальное, и не хранит состояние контента: каждая конечная точка возвращает JSON, а вызывающая сторона (B1Admin) сохраняет результат через ContentApi (POST /content/pages/temp/ai сохраняет сгенерированный пакет страница-разделы-элементы одним вызовом).
По состоянию на 2026-07-03 точки входа B1Admin в этот конвейер — шаблон сайта «AI» в AddPageModal, кнопка перезаписи SectionToolbar и кнопка «Generate Site» в списке страниц — закомментированы на стороне клиента, пока функция перерабатывается. Конечные точки AskApi ниже не затронуты и по-прежнему отвечают; скрыт только интерфейс B1Admin.
| Конечная точка | Назначение |
|---|---|
POST /website/generatePageOutline → generateSection | Исходный двухшаговый поток страницы: сначала план, затем один вызов на раздел. Шаблон страницы «AI» в AddPageModal B1Admin управляет этим — план, затем параллельная генерация разделов, затем предпросмотр |
POST /website/generateSite | Генерация всего сайта. Намеренно двухфазная: вызов с planOnly: true возвращает только многостраничный план (один быстрый вызов модели), затем клиент запрашивает полный контент — удерживая каждый запрос в пределах тайм-аута Lambda/API Gateway |
POST /website/rewriteSection | Перезапись с сохранением структуры: модель может менять только текстонесущие ответы. Рекурсивная сигнатура структуры (id + типы + порядок) сравнивается до и после; любое несовпадение возвращает исходный раздел с fallback: true вместо повреждённой структуры |
POST /website/generateAltText | Вызов зрения над до 20 URL изображений; возвращает лаконичный alt-текст (≤125 символов, префиксы «photo of» удаляются) |
POST /website/generateMetaDescription | Одно SEO-метаописание (≤155 символов) из текстового содержимого страницы — подключено к кнопке Generate на настройках страницы B1Admin |
Подсказки — это markdown-файлы в AskApi/config/instructions/, включая каталог элементов, из которого генерирует модель. Два дизайн-решения удерживают каталог честным: клиент передаёт availableElementTypes при каждом запросе (подсказка может использовать только типы из этого списка — сервер никогда не зашивает полный набор в код), а инструмент MCP API describe_page_builder несёт тот же справочник для ИИ-агентов, работающих через MCP. Модели — это Anthropic Claude через OpenRouter — 3.5 Haiku для контента разделов (задержка), 3.5 Sonnet для планов, макетов сайта и зрения — с резервом на OpenAI, когда ключ OpenRouter не настроен.
Разговорные формы
Формы (модуль membership) получили разговорный режим, ориентированный на страницы в стиле карточки контактов. Четыре столбца в forms управляют этим: displayMode (standard | conversational), autoCreatePerson, followUpSubject, followUpBody.
- Рендеринг —
FormSubmissionEditapphelper переключается на компонентConversationalForm(по одному вопросу за раз), когдаdisplayModeравенconversational; страница формы B1App передаёт режим дальше. Полезная нагрузка отправки одна и та же в обоих случаях. - Автосоздание человека — при отправке с установленным
autoCreatePersonConversationalFormHelper.findOrCreatePersonдедуплицирует по email (без учёта регистра) и иначе создаёт домохозяйство + человека соmembershipStatus: "Guest", затем связывает отправку с этим человеком. - Письмо продолжения — когда заданы тема и тело, отправитель получает шаблонное письмо (с токенами
{firstName}/{churchName}) через существующий транзакционный путь (TransactionalEmailHelper), никогда не через дверь дайджеста уведомлений. Оба побочных эффекта не критичны: сбой никогда не приводит к потере отправки.
Эти четыре поля сегодня устанавливаются через API; редактор форм B1Admin пока их не раскрывает.
Связанные страницы
- Маршрутизация веб-сайтов и мульти-сайт — как запрос разрешается в церковь/сайт и как маршрутизируются пользовательские домены
- Конечные точки Content — полная REST-поверхность для страниц, разделов, элементов, блоков, постов, редиректов и настроек
- AppHelper — npm-пакет, поставляющий рендереры, реестр, разделители и виджеты
- MCP-сервер — включая инструмент-справочник
describe_page_builder - Редактор страниц (для конечного пользователя) — документация редактора, ориентированная на персонал