@omeron/ota-content-schema (0.8.3)
Installation
@omeron:registry=npm install @omeron/ota-content-schema@0.8.3"@omeron/ota-content-schema": "0.8.3"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
Nineteen 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), plus the 0.8.0 band fields: layout? (classic | band), eyebrows?[], lead?, trustChips?[], overlay?, motion?, backgroundMedia?, mobileBackgroundMedia?. layout absent renders the pre-0.8.0 hero unchanged. 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. |
chip-row |
variant? (rule | scatter), heading?, body?, items[] of { label, overline?, icon?, accent? }. Non-linking chips — a trust bar (rule) or a wrapping tag field (scatter). Exists because ctaItem.href is required and these navigate nowhere. |
logo-wall |
variant? (strip | cards), eyebrow?, heading?, body?, caption?, columns? ('2'-'6'), items[] of { name, logo?, meta?, href?, iataCode? }, footnote?, footnoteLink?. An item with no logo renders its name as type, so a wall ships before any partner supplies a mark. Since 0.8.2 an airline item may carry iataCode ("LH"): with no uploaded logo, the renderer shows the platform's own carrier mark — the one the flight results use. An implausible code is dropped as a field, never failing the wall. |
feature-grid |
heading?, body?, caption?, columns? ('2'-'4'), items[] of { heading, body?, icon?, accent?, tone? }, note? ({ badge?, body }). note is the disclaimer band — a benefits grid that over-promises is a consumer-protection problem, not a design one. |
feature-band |
tone? (paper | ink), mediaSide?, eyebrow?, heading?, body?, chips?[], mediaKind? (image | timeline), mediaImage?, mediaTimeline?. Media is flattened, not a nested union — see the type's own comment for why, and note that mediaKind: 'image' with no mediaImage is representable and must be guarded in the CMS and the renderer, not here. |
header |
navLinks?[] of { label, href, newTab? }, showLanguageSwitcher?, showLogin?, layout? (overlay | solid), cta? ({ label, href }). Logo, language switch and login are built in. solid is an in-flow header on its own ground, which is what the band hero needs — an absolutely-positioned header contributes no height to the fold budget while still covering the art. |
footer |
columns?[] of { heading?, links[] }, and since 0.8.1 layout? (full | compact), copyrightSince? (a year), copyrightNote?. Legal/cookie/copyright links are built in and render in both layouts — compact changes the shape, never the legally required links. An implausible copyrightSince is dropped as a field rather than failing the section, because a failed footer takes those links with it. |
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
imageRef, optionalContent and the array-row trap (0.8.0)
imageRef accepts either a plain URL string or a Payload upload document populated at
depth=1, and normalises both to { url, alt?, width?, height? }. That is what lets the CMS move
to upload fields without the portal or this contract learning what a Payload document looks
like. A cleared upload, or one with no usable url, resolves to absent rather than to
{ url: '' } — background-image: url('') re-requests the current document as an image.
optionalContent is emptyToUndefined plus two more shapes, and every optional field in a
0.8.0 section uses it. The reason is narrow and load-bearing: the portal's adaptSections
strips Payload's null/{} "unset" markers only at the top level of a block. Nothing strips
them inside an array row, and there Payload has two further ways to say empty — a cleared text
column comes back as '', and a cleared localized column under ?locale=all comes back as
{ en: '' }, not {}. Both fail validation, and because zod object parsing is all-or-nothing,
one blank optional field in one row drops the entire section from a live homepage.
The blank check is recursive, because Payload nests its blanks: an optional group the
author never opened comes back as an object of nulls — cta: { label: {}, href: null } — not as
null or {}. Read as present, that group's own required fields fail and the whole section is
dropped. header is a live section type, so a non-recursive check would have taken the header
off every tenant's homepage the day header.cta shipped.
Locale records are normalised the same way: Payload writes one column per locale, so a locale
nobody has translated yet is null, and { en: 'Fares', de: null } would otherwise fail and
drop the section rather than the one missing translation. A record left with nothing is still
rejected — the "omit the field, get the built-in i18n string" contract is not softened.
false and 0 are values, not emptiness: the check is strict equality, so motion: false
survives.
parseMarkedText
parseMarkedText('Now get the [[flight right]].')
// [{ text: 'Now get the ', marked: false }, { text: 'flight right', marked: true }, …]
One authored field carries a highlighted phrase without the author writing markup. The renderer
interpolates each segment rather than binding [innerHTML], so Angular escapes it and the
sanitizer never sees anything to strip. It is one field rather than a
headline/highlight/suffix triple because German and Hindi reorder the sentence. Unbalanced
or empty markers come back verbatim as unmarked text — a typo in a heading must not drop a
section.
The <field>Mobile convention
Every prose field on the 0.8.0 sections — and the hero's headline, subheadline and lead —
has an optional <field>Mobile sibling carrying the short form for narrow viewports. Absent
means "use the base string at every width".
The renderer emits both and toggles them with display utilities. It does not pick one: this
is a server-rendered page with no viewport to branch on, and choosing on the client would be a
hydration mismatch. The portal already does exactly this for today's hero headline
(d-none d-md-block).
It exists because the mobile hero band is a fixed height with the artwork clipped to it, so a desktop-length headline there is not merely ugly, it is silently cut off — and because the search widget's offset from the top of the page, which the entire band layout is built around, is only stable if the copy above it cannot grow. A long German translation is the same hazard as long English copy.
The cost is real and worth stating: ~26 optional fields across six locales is a lot of blanks a
marketer can leave. Every one of them goes through optionalContent, so a field opened and saved
empty is absent, never a validation failure that would drop the whole band.
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 |