Перейти к содержимому
Pre-MVP. Кодовая база 1.0 не является выпущенным продуктом — см. границы продукта 1.0.

Контракт документа страницы

Заметки для разработчиков по backend/src/lib/page-contract. Это не спецификация. Граница продукта — SPEC.md §4.2 и §10. Экспортируемый контракт рендерера — SPEC.md §10.2. Граница рендерера live-API — SPEC-RENDERER.md §5.1 и §6.

Единое, не имеющее зависимостей и не зависящее от фреймворка определение документа страницы Dee Wan. Оно экспортирует константы формата, словарь слайдов, точные типы слайдов и документа, лимиты, парсеры, коды отказов и корпус фикстур для проверки соответствия.

backend/src/lib/page-contract/
refusal.ts DOCUMENT_REFUSALS, Refusal, Parsed, refuse, accept
document.ts PAGE_FORMAT, SLIDE_TYPES, LIMITS, types, parsers
fields.ts SLIDES_FIELD_KEY, TITLE_FIELD_KEY, SLIDES_FIELD_TYPE, isPageModel
fixtures.ts ACCEPTED_PAGES, REFUSED_PAGES, REFUSED_MODEL_SLIDES, STORED_DELETED_MEDIA
format2.ts PAGE_FORMAT_2, PAGE_FORMATS, SECTION_LIMITS, section types
renderer.ts renderablePage, parseAnyPageDocument, parsePublicRenderablePage, declaredFormat
migrate.ts migrateGroupToFormat2
group-structure.ts structureDrift, requireSameStructure, groupStructure, localeSaveKeepsStructure
index.ts barrel — everything except the fixtures

Фикстуры намеренно вынесены за пределы barrel-модуля. Это тестовые данные, и barrel, который подтягивал бы их, отправлял бы все фикстуры в каждый бандл, читающий хотя бы одну страницу.

Слой Владеет
page-contract формат документа, словарь слайдов, лимиты, парсеры, коды отказов
page-composer операции, планирование, промпт модели, политика записи в админке (COMPOSER_OWNED_FIELD_KEYS)
api/pages.ts авторизация, построение поиска медиа, записи жизненного цикла, сопоставление со статусами HTTP
frontend/src/lib/pages/slides.ts только представление — подписи разделов и проекции структуры
внешний рендерер HTML, CSS, вёрстка, маршрутизация, заголовки ответа

page-composer/errors.ts строит COMPOSER_ERRORS как [...DOCUMENT_REFUSALS, ...composer-only]. Коды документа там никогда не повторяются.

  • Бэкенд: page-composer/operations.ts, page-composer/planner.ts, api/pages.ts, lib/lifecycle/service.ts.
  • Фронтенд: src/lib/pages/slides.ts реэкспортирует его по относительному пути (../../../../backend/src/lib/page-contract) — так же, как типы $prisma/generated попадают в приложение админки. Ничего не копируется вручную.

Третьего рукописного словаря нет, и frontend/cypress/specs/page-composer-parity.test.ts падает, если он появится.

Все три разбирают один и тот же документ. Различаются правило для медиа и то, что означает отсутствие документа.

Точка входа Источник Медиа Отсутствующий документ Вызывающий
parseSlide(raw, id, {origin:'model', media}) ответ языковой модели разрешается через MediaLookup сайта, затем разрешённые значения проверяются по контракту записанных медиа; неразрешённый id — это unknown_media н/д генерация и ревизия
parsePageDocument(raw, {origin:'stored', media}) документ, записанный этим продуктом разрешается через поиск; и разрешённая строка, не прошедшая контракт, и удалённая строка откатываются к записанной ссылке пустая страница композер в админке
parsePublicPageDocument(raw) fields.slides публичного конверта никакого поиска — записанные media_id, url и alt проверяются как есть unreadable_page рендерер

Каждое значение, попадающее в любую из них, недоверенное.

Инвариант: что пишет композер, то может прочитать рендерер

Заголовок раздела «Инвариант: что пишет композер, то может прочитать рендерер»

Каждый документ, который сохраняет создание или ревизия, должен проходить parsePublicPageDocument. Это обеспечивают две независимые защиты, и test/page-contract-publishable.test.ts доказывает каждую по отдельности:

  1. Разрешённые медиа проверяются. MediaLookup отвечает из media.url — сохранённой строки. Импорт может записать туда что угодно — media-import.ts проверяет только, что source_url не пуст, — поэтому строка, содержащая javascript:alert(1), раньше давала сохранённую страницу, которую публичный парсер отклонял. Теперь медиа от модели проверяются по тому же контракту записанных медиа, который применяет публичное чтение, и страница отклоняется, а не лишается изображения.
  2. Весь документ перечитывается перед каждой записью. api/pages.ts запускает parsePublicPageDocument над построенным документом и в обработчике создания, и в обработчике ревизии, до saveContent. При неудаче ничего не сохраняется.

Вторая защита избыточна, пока первая корректна, и в этом весь смысл: изменение одного только парсера оставляет маршрут по-прежнему отказывающим; пропустить плохую страницу позволяет лишь изменение обоих.

Следствие, о котором нужно знать. Страница, сохранённая до появления этого правила, у которой строка медиа непригодна, по-прежнему открывается в админке — иначе её никогда нельзя было бы исправить ревизией до валидной страницы, — но она не сохранится, пока медиа не исправлено или слайд не удалён. Отказ называет поле. Это сделано намеренно: альтернатива — молча удалить изображение, которое редактор не просил удалять.

Почему публичный парсер принимает один аргумент

Заголовок раздела «Почему публичный парсер принимает один аргумент»

Рендеринг опубликованной страницы никогда не должен требовать базы данных админки. Параметр поиска медиа — это замаскированная база данных: как только он появляется в сигнатуре, рендереру нужно что-то в него передавать. test/page-contract-boundary.test.ts проверяет parsePublicPageDocument.length === 1.

  • Неизвестное медиа. Только 'model' может выдать unknown_media. Это защита от межарендаторного доступа для медиа слайдов: поиск строится из строк медиа этого сайта, поэтому id откуда-либо ещё ни во что не разрешается.
  • Удалённая строка медиа. 'stored' сохраняет то, что записал документ, поэтому удаление одного изображения не делает существующую страницу нечитаемой и, следовательно, недоступной для ревизии. 'public' читает те же записанные значения и выдаёт то же медиа — после удаления строки документ является единственным источником для обеих сторон.
  • Некорректно сформированный записанный объект медиа. 'stored' сохраняет то, что содержит документ, и никогда не отказывает, поэтому повреждённая страница остаётся открываемой и, следовательно, исправимой в админке. 'public' отклоняет всю страницу. Это расхождение намеренное и является единственным местом, где два прочтения различаются; описанная выше проверка перед сохранением не даёт ему стать способом сохранить страницу, которую нельзя отрендерить.
  • Отсутствующий документ. 'stored' читает его как пустую страницу — страницу, ожидающую генерации. 'public' отказывает: рендеринг пустого документа поместил бы пустую индексируемую страницу на живой сайт с кодом 200.

SLIDE_MEDIA_KEYS объявляет для каждого типа слайда ключи, значением которых является SlideMedia, вместе с функцией доступа, которая его читает. pageMediaReferences(document) выводится из него и возвращает каждую ссылку на медиа в документе вместе с id слайда, типом слайда и ключом, под которым она найдена.

Этого требует вызывающий код graduation. Его проверка хранения медиа сканирует сгенерированные столбцы <key>_id, а медиа страниц находятся не в столбце — они внутри документа slides, — поэтому страница с медиа, прочитанная как не содержащая медиа, экспортировала бы сайт, изображения которого разрешаются только через CMS.

Объявление охватывает ключи слайда ВЕРХНЕГО УРОВНЯ. Тип слайда, который вкладывает ссылку на медиа внутрь списка, должен расширить форму, а не объявлять ключ списка; тест корпуса обходит разобранные документы в поисках любой пары media_id/url, до которой объявление не может добраться.

Записанный URL медиа разбирается через new URL, а не сопоставляется по префиксу. startsWith('https://') — это не проверка URL: он принимает https:// без хоста, https://user:pass@cdn.example/a.jpg и — поскольку браузер нормализует обратную косую черту в прямую — /\evil.example/a.jpg, который относительно https://site.example разрешается в https://evil.example/a.jpg.

Форма Результат
https://cdn.example/a.jpg (query и fragment допускаются) принимается
/local-media/abc — ровно одна ведущая косая черта принимается
http://localhost…, http://127.0.0.1…, http://[::1]… принимается
http:// с любым другим хостом отклоняется — смешанное содержимое на опубликованной странице
https:// без хоста отклоняется
https://user:pass@host/a.jpg отклоняется — учётные данные утекают через referrer и кеши
//evil.example/a.jpg отклоняется — протокол-относительный URL ведёт на чужой origin
/\evil.example/a.jpg или любая обратная косая черта отклоняется
javascript:, data:, любая другая схема отклоняется
встроенные пробельные символы, разметка, длина свыше LIMITS.href отклоняется

HTTP через loopback разрешён только потому, что локальный стенд отдаёт медиа с http://localhost:8787/local-media/…; медиа в продакшене должны быть HTTPS или относительными к сайту.

Alt-текст ограничен по длине и проверяется по типу, но не проверяется на разметку. Это подпись к медиа, написанная автором, а не вывод модели, и она попадает в экранированный атрибут; отклонять страницу из-за угловой скобки в подписи означало бы ломать сайт из-за пунктуации.

Навигационные ссылки строже, чем URL медиа: только относительные к сайту, https:// или mailto:.

frontend/src/lib/pages/slides.ts экспортирует readPageDocument(value), который возвращает { ok: true, document } или { ok: false, code, message }.

Это намеренно не предикат типа. Guard value is PageDocument был некорректен: парсер принимает больше форм, чем выдаёт, — сериализованную JSON-строку и документ, у которого необязательные ключи опущены, а не равны null, — и нормализует их. Возврат true об исходном значении сообщал компилятору, что у строки есть .slides.

Обещанную форму имеет только parsed.value. Поэтому fetch-store/pages.ts при каждом чтении и записи страницы заменяет собственный document ответа на разобранный, чтобы ни один экран никогда не читал ненормализованный слайд.

Отказ — это { ok: false, error: { code, message, detail } }: простой объект, а не выброшенная ошибка и не класс. Проверка instanceof через границу пакета не срабатывает, когда существуют две копии одного и того же класса, а выброшенная ошибка не переживает JSON. api/pages.ts сопоставляет код со статусом; фронтенд сопоставляет его с текстом в COMPOSER_REFUSAL_COPY.

  1. Добавьте его в SLIDE_TYPES, добавьте его точный тип, добавьте его в объединение Slide.
  2. Добавьте записи в SLIDE_KEYS, SLIDE_OPTIONAL_KEYS, SLIDE_VARIANT_KEYS и SLIDE_MEDIA_KEYS — все четыре индексированы по SlideType, поэтому, пока этот шаг не выполнен, будет ошибка компиляции. SLIDE_MEDIA_KEYS проверяется строже остальных: тип слайда, содержащий SlideMedia, не может объявить [], а ключ, не являющийся медиа, вообще не может быть объявлен.
  3. Добавьте case в parseSlide.
  4. Добавьте фикстуры в ACCEPTED_PAGES, покрывающие тип с каждым присутствующим необязательным значением, с каждым необязательным значением, равным null, и с каждым перечисляемым значением.
  5. Добавьте подпись в SLIDE_LABELS и ветку в каждую проекцию в frontend/src/lib/pages/slides.ts (slideOwnHeading, slideLead, slideParagraphs, slideFeatures, slideMedia, slideLink) — каждая из них является исчерпывающим switch, поэтому это тоже ошибка компиляции, пока шаг не выполнен.
  6. Добавьте компонент рендерера в репозиторий рендерера.

Шаги 1–5 при пропуске громко падают. test/page-contract-corpus.test.ts падает на типе без фикстуры; frontend/src/lib/pages/slides.test.ts падает на типе, для которого админка ничего не рисует; PagePreview.svelte.test.ts падает на типе, который не попадает ни в одну карточку; test/page-contract-media.test.ts падает на документе, содержащем ссылку на медиа, до которой объявление не может добраться, — это тот случай, который иначе пропустило бы объявление только верхнего уровня.

Новому типу слайда не нужно повышение формата. Добавление типа расширяет то, что может храниться; оно не меняет того, как читается существующий документ.

format — это граница миграции. parsePageDocument отклоняет всё, что не равно в точности PAGE_FORMAT, а parseAnyPageDocument отклоняет всё, что вне PAGE_FORMATS, — отсутствующий и более новые форматы приводят к отказу (fail closed). Приём более нового формата означал бы чтение полей более поздней сборки по сегодняшним правилам, отбрасывание того, что не распознано, и запись этого прочтения с потерями поверх настоящего документа.

Повышение требует, по порядку:

  1. миграции ядра, переписывающей сохранённые документы, или явного письменного решения о совместимости;
  2. обновлённого контракта;
  3. выпущенной поддержки в рендерере;
  4. фикстур соответствия для обоих форматов;
  5. задокументированного порядка обновления для владельцев.

Не повышайте PAGE_FORMAT, чтобы добавить поле или тип слайда.

PageDocument.format типизирован как typeof PAGE_FORMAT, а не number, поэтому { format: 999, slides: [] } — это и ошибка компиляции, и отказ во время выполнения. test/page-contract-corpus.test.ts удерживает это строкой @ts-expect-error, которая перестанет компилироваться, если тип когда-либо снова расширится.

fixtures.ts намеренно написан вручную. test/page-contract-corpus.test.ts читает SLIDE_TYPES, SLIDE_OPTIONAL_KEYS, SLIDE_VARIANT_KEYS, LIMITS и DOCUMENT_REFUSALS и падает, пока корпус не задействует каждый из них:

  • каждый тип слайда встречается в разобранном принятом документе;
  • каждый необязательный ключ встречается и со значением, отличным от null, и с null;
  • каждый перечисляемый ключ встречается с каждым значением;
  • каждый код отказа выдаётся какой-либо отклонённой фикстурой;
  • каждый лимит превышается отклонённой фикстурой, detail отказа которой содержит именно этот max.

Покрытие измеряется по тому, что возвращает парсер, а не по тому, что заявляет фикстура. Корпус, сгенерированный из этих списков, совпадал бы с ними по построению и никогда не смог бы упасть — не пишите такой.

ACCEPTED_PAGES и REFUSED_PAGES оцениваются через parsePublicPageDocument. Поведение для источников model и stored отражено отдельно в REFUSED_MODEL_SLIDES и STORED_DELETED_MEDIA.

Контракт должен работать в workerd без какой-либо CMS вокруг. Он не может импортировать ничего, кроме своих соседних модулей, — никакого Hono, никакого Prisma, никакого Svelte, никакого Astro, никаких модулей node:, никакого кода базы данных.

test/page-contract-boundary.test.ts проверяет это дважды и независимо:

  1. он читает каждый спецификатор модуля в каталоге и требует, чтобы каждый соответствовал ^\./[A-Za-z0-9_-]+$;
  2. он собирает index.ts через esbuild с platform: 'neutral' за плагином-резолвером, который отклоняет каждый нерелятивный спецификатор, а затем проверяет вывод на require( и node:.

Ни один из них не запускает сам workerd. Вместе они доказывают, что модуль самодостаточен, — а именно это свойство нужно для сборки под workerd.

Как внешний рендерер будет использовать его позже

Заголовок раздела «Как внешний рендерер будет использовать его позже»

Сегодня контракт находится в этом репозитории и используется по относительному пути. Репозиторий рендерера будет использовать закреплённый неизменяемый артефакт того же каталога и никогда не должен копировать типы вручную. Обновление контракта тогда становится явным изменением зависимости.

Имя артефакта, его расположение и механизм публикации не определены и требуют полномочий владельца проекта. Ничто в этом репозитории их не называет. Пока их нет, относитесь к экспортируемому интерфейсу так, будто он уже неизменяем: аддитивные изменения дёшевы, переименования и изменения формы — нет.

Рендереру от артефакта нужны ровно три вещи:

  • parsePublicPageDocument — вся валидация, один аргумент, без базы данных;
  • SLIDE_TYPES и типы слайдов — для реестра компонентов на этапе сборки с исчерпываемостью, проверяемой при компиляции;
  • ACCEPTED_PAGES / REFUSED_PAGES — чтобы доказать, что его собственный парсер согласуется с парсером ядра.

Он не должен импортировать page-composer ни для чего.