@omeron/ota-content-schema (0.5.0)

Published 2026-07-28 04:15:18 +00:00 by omeron-admin

Installation

@omeron:registry=
npm install @omeron/ota-content-schema@0.5.0
"@omeron/ota-content-schema": "0.5.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:

  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

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-07-28 04:15:18 +00:00
9
UNLICENSED
14 KiB
Assets (1)
Versions (6) View all
0.7.0 2026-07-30
0.6.0 2026-07-28
0.5.0 2026-07-28
0.4.0 2026-07-27
0.3.0 2026-07-27