@omeron/ota-content-schema (0.6.0)
Installation
@omeron:registry=npm install @omeron/ota-content-schema@0.6.0"@omeron/ota-content-schema": "0.6.0"About this package
@omeron/ota-content-schema
The canonical contract for tenant homepage content in the OTA platform. It defines the ordered list of typed "sections" a tenant's homepage/header/footer is composed of, as both:
- zod schemas — runtime validation at the trust boundary, and
- TypeScript types — inferred from those schemas (single source of truth).
Who consumes it
| Consumer | Role |
|---|---|
| ota-portal | Renders sections via HomeSectionsComponent. Validates CMS output with parseSections() before rendering. |
| ota-cms (Payload) | Authors sections. Payload blocks mirror these schemas 1:1 (block slug = section type). |
Splitting the schema into one versioned package is what prevents schema drift between the renderer and the author. Change a section shape here, bump the version, update both consumers.
zod is a peer dependency (^3.23.8), deliberately: this package's job is to be the shared
type identity between two independently deployed repos. As a hard dependency, a consumer on a
different zod would end up with a second nested copy — two zods in the portal's browser bundle, and
instanceof z.ZodError / z.object({ hero: heroSection }) failing across instances. Install zod
alongside this package.
Usage
import { parseSections, type HomepageSection } from '@omeron/ota-content-schema';
// At the portal's CMS fetch boundary, after mapping Payload `blockType` -> `type`:
const sections: HomepageSection[] = parseSections(rawFromCms); // drops anything malformed
Sections
Fifteen types, all discriminated on type. SECTION_TYPES lists them — build the CMS block
registry from that array; a test pins it to the zod union, so the two cannot drift.
| Type | Shape |
|---|---|
hero |
backgroundImage (required, non-empty), mobileBackgroundImage?, headline?, subheadline?, showFareTeaser (defaults to true). Omit headline/subheadline and the portal renders its built-in i18n strings. The search widget is locked chrome, not part of this schema. .default(true) makes showFareTeaser required on HomepageSection (the parsed output type) — authoring code should type against HomepageSectionInput instead, where it's optional, so { type: 'hero', backgroundImage: '/x' } compiles. |
cta-row |
heading?, items[] of { label, href, icon? } |
destination-links |
heading?, items[] of { label, href, imageUrl?, caption? } |
banner-carousel |
slides[] of { imageUrl, mobileImageUrl?, href?, alt?, newTab? } — one slide = static banner, many = carousel. alt is localizedText, which rejects '': a decorative slide must omit alt entirely (the renderer maps absent → alt="", the WCAG-correct encoding for decorative images under the EU Accessibility Act). Writing alt: '' doesn't produce alt="" — it fails validation and drops the whole slide. |
rich-html |
html: z.record(z.string(), z.string()) — a free-form string-keyed record of pre-sanitized HTML (escape hatch). Despite the name, keys are not restricted to LOCALES and {} validates; tightening this to match LocalizedText is a separate, deferred decision. |
header |
navLinks?[] of { label, href, newTab? }, showLanguageSwitcher?, showLogin?. Logo, language switch and login are built in. |
footer |
columns?[] of { heading?, links[] }. Legal/cookie/copyright is built in, not editable. |
travel-guide, destinations-cards, flights-list, payment-methods, carousel-thumbnails, destinations-grid, support, newsletter |
Toggle wrappers over the portal's self-fetching components — { type } only, no config in v1. |
The six required plain-string fields — hero.backgroundImage, bannerSlide.imageUrl,
ctaItem.href, destinationLink.href, headerNavLink.href, footerLink.href — got .min(1) and
reject ''. Payload serialises a cleared text field as the empty string, and
background-image: url('') resolves to the current document — the browser re-requests the page as
an image.
This does not cover every href in the schema: bannerSlide.href is optional and still accepts
'', which renders <a href=""> — a link that reloads the page instead of navigating anywhere.
LocalizedText
type LocalizedText = string | { en?: string; de?: string; es?: string; fr?: string; hi?: string; ro?: string };
The guarantee: if the field is present, it carries renderable text.
localizedText.parse('Fly further'); // ok
localizedText.parse({ en: 'Fly further' }); // ok
localizedText.parse(''); // throws
localizedText.parse({}); // throws
localizedText.parse({ en: '' }); // throws — no non-empty locale
That is what makes the hero's fallback contract real. Without it, an author who opens headline and
saves it blank produces a value that is present but empty, and a renderer branching on
headline !== undefined puts an empty <h1> on the page instead of falling back to i18n.
Keys outside the six supported locales (LOCALES: en, de, es, fr, hi, ro) are
stripped, not rejected, so adding a locale to the portal stays forward compatible for content
already in the CMS. { en: 'Hi', it: 'Ciao' } loses only the Italian value and still validates. But
if the only value present is an unsupported locale, stripping it leaves nothing behind: { it: 'Ciao' } fails validation the same as {} would, and inside a section that fails parseSections
drops the entire section, not just the one field. onDrop is how you observe that a whole
section vanished because every locale it carried was unsupported.
parseSections and dropped sections
function parseSections(input: unknown, onDrop?: SectionDropHandler): HomepageSection[];
type SectionDropHandler = (index: number, error: z.ZodError, raw: unknown) => void;
parseSections never throws. Non-array input yields [], and any element that fails validation
is dropped while the survivors keep their relative order — one malformed block must not blank a
homepage.
The cost is that dropping is invisible: a portal running an older copy of this package renders a
page missing sections a marketer swears they published. Pass onDrop to observe it — it fires once
per dropped element, with that element's index in the input array:
const sections = parseSections(rawFromCms, (index, error, raw) => {
logger.warn('cms section dropped', { index, issues: error.issues, raw });
metrics.increment('cms.section.dropped');
});
onDrop is optional and additive — existing single-argument calls behave exactly as before.
Forward compatibility
zod strips unknown keys by default, so adding an optional field to a section is
forward-compatible: a portal on an older copy of this package ignores the new field and keeps
rendering the section. Removing a field, renaming one, or tightening validation is breaking —
already-authored content stops parsing and parseSections silently drops whole sections from live
homepages. Do those before a consumer exists, or behind a version bump rolled out to both repos.
DEFAULT_HOMEPAGE_SECTIONS
const DEFAULT_HOMEPAGE_SECTIONS: readonly Readonly<HomepageSection>[];
The stock homepage, in render order. It has two roles at once:
- ota-cms seeds every new tenant's page with it.
- ota-portal renders it when a tenant has no CMS content or the CMS is unreachable.
Because the seed is the fallback, rollout is a visual no-op — nothing changes until a marketer edits. That equality is the whole point, which is why the two must never drift apart; a deep-equal test pins the entire constant, so any content change has to be deliberate and reviewed.
It is a process-wide singleton inside a long-lived multi-tenant SSR server, so it is readonly at
compile time and deep-frozen at runtime. Mutating it — a Payload seed hook stamping id onto a
block, a tenant swapping the hero image — would corrupt the fallback for every other tenant, so it
throws instead. Copy before you modify:
const seed = structuredClone(DEFAULT_HOMEPAGE_SECTIONS) as HomepageSection[];
Order: hero, travel-guide, destinations-cards, flights-list, carousel-thumbnails,
destinations-grid, support, newsletter, payment-methods, footer. The hero omits
headline/subheadline (i18n fallback), and the footer omits columns — the portal's legacy five
columns were 53 dead href="#" nofollow links with untranslated English labels, deliberately not
migrated.
Build
npm install
npm run build # tsc -> dist/ (JS + .d.ts; sourcemaps off, they would dangle outside the tarball)
npm run typecheck # no-emit type check, tests included
npm test # vitest
Dependencies
Development Dependencies
| ID | Version |
|---|---|
| typescript | ^5.5.4 |
| vitest | ^2.1.9 |
| zod | ^3.23.8 |
Peer Dependencies
| ID | Version |
|---|---|
| zod | ^3.23.8 |