Dual-mode, цены из Supabase и две инфра-гочи: как мы перестроили webshturm.ru не ломая SEO

Веб Штурм
  • Astro
  • Supabase
  • dual-mode
  • SSR
  • AEO
  • rehype
  • remark
  • content-collections
  • vitest

Три волны модернизации webshturm.ru в июне 2026: светлая тема, dual-mode «Разработчику/Руководителю» и управление ценами из базы данных без передеплоя. Этот пост — технический разбор архитектурных решений и двух инфра-гочи, которые могли положить формы на проде.

О результатах — в кейсе «Dual-mode, глоссарий и управление ценами из Supabase». Здесь — как устроено и где мы споткнулись.

Dual-mode: параллельные коллекции, не client-side тогл

Задача: два варианта контента (технический и бизнес-ориентированный) на одном Astro-сайте, оба индексируются в поиске, переключение без серверных редиректов.

Первая идея — один контент, client-side JavaScript показывает нужный вариант по localStorage. Отвергли сразу: Googlebot и Яндекс.Бот видят HTML без JS. Один URL — одна версия в индексе, нельзя продвигаться под два разных интента одновременно.

Выбрали параллельные Astro content collections:

src/content/
  services/           ← dev-URL (текущие URL, SEO-equity сохранён)
  cases/
  blog/
  biz/
    services/         ← /biznesu/services/* (manager-URL)
    cases/
    blog/

Оба дерева статически рендерятся при сборке. Схемы коллекций — общие переменные (без дублирования Zod-объектов). Каждая страница self-canonical — краулер видит тот URL, который запросил.

AudienceToggle (Svelte 5 runes)

Тоглер в Navbar вычисляет зеркальный путь через toMirror(pathname):

// src/lib/audience.ts (упрощённо)
export function toMirror(pathname: string): string {
  // нормализуем ведущие слэши — guard от open-redirect
  const clean = pathname.replace(/^\/+/, '/');
  if (clean.startsWith('/biznesu/')) return clean.replace('/biznesu/', '/');
  return '/biznesu' + clean;
}

Выбор запоминается в localStorage (ключ webshturm.audience.v1) и в cookie — для серверной согласованности. Тоглер скрывается, если у страницы нет biz-зеркала (dual-mode выкатывается постепенно).

Гоча с open-redirect: при pathname='/biznesu//evil.com' функция toMirror могла вернуть '//evil.com' — protocol-relative URL, который браузер трактует как https://evil.com. Поймали автоматическим security-review после слияния инфра-фазы. Фикс: replace(/^\/+/, '/') нормализует ведущие слэши до вызова логики переключения. Компонент дополнительно проверяет, что путь начинается с /^\/[^/]/.

Анти-каннибализм: машинный гейт

dev↔biz пары с одним slug должны различаться по title и excerpt (для blog), title (для cases), metaTitle/metaDescription/title (для services). Ручная проверка при 60+ страницах — ненадёжна.

src/lib/cannibalization.ts + vitest-тест читают оба дерева через gray-matter и проверяют каждую пару:

// src/tests/seo/cannibalization.test.ts (фрагмент)
const blogPairs = buildPairs(devBlog, bizBlog, 'blog', ['title', 'excerpt']);
// ...
it('нет пар с совпадающими SEO-полями (каннибализм)', () => {
  const conflicts = findCannibalConflicts(allPairs);
  expect(conflicts).toHaveLength(0);
});

Тест падает при совпадении любого из полей — CI-деплой блокируется. Это называется machine-enforced anti-cannibalism: нельзя случайно опубликовать одинаковые title/excerpt в двух коллекциях.

rehype-глоссарий и кириллица

rehype-glossary.mjs — плагин, который обходит AST скомпилированного HTML и оборачивает первое вхождение термина из glossary.ts в <span class='gloss'> с тултипом.

Первая реализация использовала стандартный \b:

// Не работает для кириллицы:
const re = new RegExp(`\\b${escapeRegex(term)}\\b`, 'gi');

Проблема: \b в JS — ASCII word-boundary. Символы А–Я, ё, Ё не входят в \w. Результат — термин «ОТБ» мог захватываться внутри слова «протокол» (буква «т» — ASCII, и \b срабатывала на ней). На коротких терминах это давало ложные совпадения.

Решение — Unicode lookarounds:

// Работает для кириллицы, дефис-терминов и слэш-терминов:
const boundary = String.raw`(?<![а-яА-ЯёЁa-zA-Z0-9\-\/])`;
const boundaryEnd = String.raw`(?![а-яА-ЯёЁa-zA-Z0-9\-\/])`;
const re = new RegExp(`${boundary}${escapeRegex(term)}${boundaryEnd}`, 'gi');

Дефис и слэш добавлены в класс, чтобы термин «CI/CD» не захватывался внутри составных слов.

Тултип позиционирован position: absolute — не влияет на CLS (Core Web Vitals — совокупный сдвиг раскладки).

Цены из Supabase: запекание + fail-open

Задача

Калькуляторы (оценка SEO-аудита, Pentest, Legacy-миграции) хранили коэффициенты в TypeScript-константах. Изменить — только через коммит и деплой. Цена отображалась статически.

Требования:

  1. Редактировать коэффициенты из Supabase Studio без передеплоя.
  2. Скрывать/показывать блок цен без передеплоя.
  3. Цена должна быть в статическом HTML — видна поисковым и AI-ботам без JS (AEO — Answer Engine Optimization).

Схема в Supabase (миграции 0007–0010)

-- Конфигурации калькуляторов
CREATE TABLE webshturm.calc_configs (
  service_id TEXT PRIMARY KEY,
  config     JSONB NOT NULL,
  is_active  BOOLEAN DEFAULT TRUE,
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Флаги видимости блоков цен
CREATE TABLE webshturm.price_blocks (
  service_key TEXT PRIMARY KEY,
  is_visible  BOOLEAN DEFAULT TRUE,
  note        TEXT
);

-- История через BEFORE UPDATE триггеры
CREATE TABLE webshturm.calc_configs_versions (...);
CREATE TABLE webshturm.price_blocks_versions (...);

RLS включён, GRANT только для service_role. anon и authenticated доступа не имеют.

API-эндпоинты (fail-open)

/api/pricing/calculators и /api/pricing/visibilityAstro server endpoints (prerender=false):

// src/pages/api/pricing/visibility.ts (упрощённо)
import type { APIRoute } from 'astro';
import { supabaseAdmin } from '../../../lib/supabase-admin';

// In-memory кэш: не Redis, не edge KV — просто Map в Node-процессе
let cache: { data: Record<string, boolean>; ts: number } | null = null;
const TTL_MS = 60_000; // 60 секунд

export const GET: APIRoute = async () => {
  if (cache && Date.now() - cache.ts < TTL_MS) {
    return new Response(JSON.stringify(cache.data), { status: 200 });
  }

  const { data, error } = await supabaseAdmin
    .from('price_blocks')
    .select('service_key, is_visible');

  if (error) {
    // fail-open: не 500, а пустой объект → компонент использует DEFAULT
    return new Response('{}', { status: 200 });
  }

  const map = Object.fromEntries(data.map(r => [r.service_key, r.is_visible]));
  cache = { data: map, ts: Date.now() };
  return new Response(JSON.stringify(map), { status: 200 });
};

Почему fail-open, а не 500 при ошибке БД. Если Supabase временно недоступна, страница с калькулятором должна продолжать работать с дефолтными коэффициентами. 500 сломает страницу и, возможно, заблокирует форму заявки. Fail-open — компромисс: пользователь видит корректный (хотя и не актуальный) расчёт, заявка приходит.

remark-директива: цена в статическом HTML

<!-- В .md файле услуги: -->
:::price{service=seo-audit}
**от 45 000 ₽** — базовый SEO-аудит (до 50 страниц)
:::

remark-price.mjs разворачивает директиву в <section data-price-block="seo-audit"> при сборке. Содержимое — статический HTML с ценой.

// remark-price.mjs (упрощённо)
export function remarkPrice() {
  return (tree) => {
    visit(tree, 'containerDirective', (node) => {
      if (node.name !== 'price') return;
      const serviceKey = node.attributes?.service ?? '';
      node.data ??= {};
      node.data.hName = 'section';
      node.data.hProperties = { 'data-price-block': serviceKey };
    });
  };
}

PriceVisibility.svelte (Svelte 5, client:idle) делает fetch /api/pricing/visibility и прячет [data-price-block]-секции при is_visible === false. Краулер и AI-боты обходят сайт без JS — видят цену. Пользователь после гидратации видит актуальный статус видимости.

Ключевой компромисс AEO: цена зафиксирована в HTML при сборке. Если цена изменилась — нужна пересборка, чтобы обновить HTML. Флаг is_visible (скрыть/показать блок целиком) — без пересборки. Разные инструменты для разных задач.

Грабли

Гоча 1: pg_dump без грантов → тихий 403 на формах

При переносе инстанса Supabase схему webshturm залили через pg_dump | psql. Всё восстановилось: таблицы, данные, RLS-политики. Запустили сайт — калькуляторы работают (статика с DEFAULT-конфигом), формы подписки работают (другая схема). Отлично.

Через час обнаружили: PostgREST возвращает 403 permission denied for schema webshturm на все запросы к service_role-endpoint’ам.

Причина: pg_dump без флага --no-owner сохраняет GRANT-инструкции, но при restore под другим суперпользователем они не применяются — владелец объекта меняется, а гранты ролям слетают. Роль service_role не получила USAGE на схему и SELECT/INSERT/UPDATE/DELETE на таблицы.

Диагностика:

SELECT has_schema_privilege('service_role', 'webshturm', 'USAGE');
-- вернул: f

Фикс:

GRANT USAGE ON SCHEMA webshturm TO service_role;
GRANT ALL ON ALL TABLES IN SCHEMA webshturm TO service_role;
GRANT ALL ON ALL SEQUENCES IN SCHEMA webshturm TO service_role;
-- И дефолт для будущих таблиц:
ALTER DEFAULT PRIVILEGES IN SCHEMA webshturm
  GRANT ALL ON TABLES TO service_role;

Урок: после любого pg_dump / psql restore — первая проверка:

SELECT has_schema_privilege('service_role', '<schema>', 'USAGE');

Если f — выдать GRANT до запуска сайта. Не после. Тихий 403 из PostgREST трудно поймать: fail-open API возвращает {} вместо ошибки — формы выглядят рабочими, но данные не пишутся.

Гоча 2: rsync только dist/client → SSR-сервер заморозился

Сайт работает на Astro node-adapter: гибридный рендеринг — статические страницы в dist/client, SSR-бандл в dist/server/entry.mjs. Исторически CI деплоил только dist/client — когда сайт был чисто статическим, этого хватало.

Добавили /api/pricing/* с prerender=false — эти роуты живут в dist/server. CI успешно синкнул dist/client, dist/server остался от предыдущего деплоя (9 мая). Новые API-роуты возвращали 404.

Почему не заметили сразу. Статические страницы обновились нормально — CI зелёный, сайт выглядит рабочим. Smoke-тест проверял /, /services/, /cases/ — все 200. Новые /api/pricing/* в список проверок не входили.

Как нашли. Вручную зашли на страницу с калькулятором, открыли DevTools — GET /api/pricing/calculators → 404. Жёсткий 404, не {} от fail-open (fail-open только при ошибке Supabase, а не при отсутствии роута).

Фикс в CI:

# .gitea/workflows/deploy.yml — было:
- name: Deploy
  run: rsync -avz --delete app/dist/client/ root@$HOST:/opt/webshturm-site/dist/client/

# Стало:
- name: Deploy
  run: rsync -avz --delete app/dist/ root@$HOST:/opt/webshturm-site/dist/

Smoke-тест расширен:

- name: Smoke
  run: |
    for route in / /services/ /cases/ /blog/ /api/pricing/visibility; do
      STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://webshturm.ru${route}")
      if [ "$STATUS" = "404" ] || [ "$STATUS" = "500" ]; then
        # rollback...
        exit 1
      fi
    done

Урок: при добавлении первого SSR-роута в статический Astro-сайт — обязательно проверить, что деплой обновляет dist/server. Добавить хотя бы один SSR-маршрут в smoke-тест. В противном случае «успешный» деплой молча оставляет старый серверный бандл.

Метод: brainstorm → spec → plan → subagent TDD

Каждая волна прошла полный цикл: /штурм (LightRAG-запрос + brainstorm + spec) → writing-plans (декомпозиция на задачи) → subagent-driven development (параллельный TDD: красный тест → код → зелёный). 285 автотестов — vitest unit + интеграционные.

Принцип fail-open применён везде: API pricing, cookie-consent, newsletter double opt-in. Ни один сбой внешней зависимости не должен ломать пользовательский путь до формы заявки.

Ссылки

Часто задаваемые вопросы

Почему для dual-mode выбраны параллельные коллекции, а не один контент с client-side переключением?
Client-side переключение — одна страница для краулера: оба варианта текста не индексируются раздельно. Параллельные коллекции дают два URL-дерева (текущие URL + /biznesu/), каждый со своим title, H1, FAQ и self-canonical. Тогл в Navbar — только UX-артефакт, краулер его не видит. Это ключевое условие для раздельного продвижения под технический и коммерческий интент.
Как цена оказывается в HTML без пересборки сайта?
Цена запекается при сборке через remark-директиву :::price{service=X} →
. Статический HTML содержит цену — поисковый и AI-бот видит её без JS. На клиенте PriceVisibility.svelte (client:idle) делает fetch /api/pricing/visibility: если is_visible===false — прячет блок. Обновление в Supabase Studio отражается на проде через ≤60 секунд без пересборки.
Что такое fail-open и почему это важно для API pricing?
Fail-open означает: при ошибке базы данных API возвращает 200 + пустой объект {}, а не 500. Калькулятор-остров видит пустой конфиг и переключается на жёстко заданные DEFAULT-значения. Сайт продолжает работать, пользователь видит корректный расчёт с дефолтными коэффициентами. Альтернатива (fail-closed, 500) — сломанная страница и потерянный лид.
Как кириллица сломала стандартные word-boundary в rehype-плагине?
Стандартное \b в JavaScript RegExp привязано к ASCII-границам: символы А–Я, ё, Ё не входят в класс \w. Это значит \b не срабатывает на границе кириллического слова — термин «СДО» мог разворачиваться внутри слова «корпоративная». Решение: Unicode lookarounds (?
Почему SSR-роуты давали 404 при успешном CI-деплое?
Сайт работает на Astro node-adapter (SSR): статика в dist/client, серверный бандл в dist/server/entry.mjs. CI исторически синкал только dist/client через rsync — достаточно для чисто статического сайта. Когда добавили /api/pricing/* (prerender=false), эти роуты оказались в dist/server, который не обновлялся. Статические страницы деплоились нормально, поэтому CI светился зелёным. Баг обнаружили по 404 на новых API-маршрутах.