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-константах. Изменить — только через коммит и деплой. Цена отображалась статически.
Требования:
- Редактировать коэффициенты из Supabase Studio без передеплоя.
- Скрывать/показывать блок цен без передеплоя.
- Цена должна быть в статическом 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/visibility — Astro 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. Ни один сбой внешней зависимости не должен ломать пользовательский путь до формы заявки.
Ссылки
- Кейс: результаты трёх волн модернизации
- Как мы построили этот сайт с нуля — технические решения первого запуска
- Subagent-driven development — паттерн координатор + рой агентов