@omeron/ota-content-schema (0.8.3)

Published 2026-09-13 19:53:50 +00:00 by omeron-admin

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:

  1. ota-cms seeds every new tenant's page with it.
  2. 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
Details
npm
2026-09-13 19:53:50 +00:00
148
UNLICENSED
latest
45 KiB
Assets (1)
Versions (10) View all
0.8.3 2026-09-13
0.8.2 2026-09-13
0.8.1 2026-09-13
0.8.0 2026-09-12
0.7.0 2026-07-30