509 lines
14 KiB
TypeScript
509 lines
14 KiB
TypeScript
import type { ArticlesBlock, BannerBlock, BannerStyle, Breadcrumb, ContentBlock, FaqBlock, LanguageAlternative, Media, MediaBlock, MediaContentBlock, NavigationLink, NavigationLinkVariant, PageBlock, PageDocument, PageHero, PageRequest, PageResolution, PageTeaser, PageTeaserList, RichText, SitemapEntry, SiteMenu, SiteSettings, UnknownBlock } from '../types';
|
|
|
|
const LINK_VARIANTS = new Set<NavigationLinkVariant>([
|
|
'primary',
|
|
'secondary',
|
|
'outline',
|
|
'ghost',
|
|
'inline'
|
|
]);
|
|
|
|
const HERO_IMPACTS = new Set(['none', 'low', 'middle', 'high']);
|
|
|
|
const BANNER_STYLES = new Set<BannerStyle>(['info', 'success', 'warning', 'error']);
|
|
|
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function asString(value: unknown, fallback = ''): string {
|
|
return typeof value === 'string' ? value : fallback;
|
|
}
|
|
|
|
function asOptionalString(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function asOptionalNumber(value: unknown): number | undefined {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
function normalizePath(value: string): string {
|
|
return value.split('/').filter(Boolean).join('/');
|
|
}
|
|
|
|
function normalizeRichText(value: unknown): RichText {
|
|
if (isRecord(value)) {
|
|
return { content: asString(value['content']) };
|
|
}
|
|
|
|
return { content: asString(value) };
|
|
}
|
|
|
|
function normalizeMedia(value: unknown): Media | undefined {
|
|
if (!isRecord(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const url = asString(value['url']);
|
|
if (!url) {
|
|
return undefined;
|
|
}
|
|
|
|
const media: Media = {
|
|
url,
|
|
alt: asString(value['alt'])
|
|
};
|
|
const width = asOptionalNumber(value['width']);
|
|
const height = asOptionalNumber(value['height']);
|
|
const mimeType = asOptionalString(value['mimeType']);
|
|
|
|
if (width !== undefined) media.width = width;
|
|
if (height !== undefined) media.height = height;
|
|
if (mimeType !== undefined) media.mimeType = mimeType;
|
|
|
|
return media;
|
|
}
|
|
|
|
function normalizeNavigationLink(value: unknown): NavigationLink | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const label = asString(value['label']);
|
|
if (!label) {
|
|
return null;
|
|
}
|
|
|
|
const rawVariant = asString(value['variant']);
|
|
const variant = LINK_VARIANTS.has(rawVariant as NavigationLinkVariant)
|
|
? (rawVariant as NavigationLinkVariant)
|
|
: 'ghost';
|
|
const rawLinkType = asString(value['linkType']);
|
|
const linkType = rawLinkType === 'internal' ? 'internal' : 'external';
|
|
const rawPageId = value['pageId'];
|
|
const link: NavigationLink = {
|
|
linkType,
|
|
pageId: typeof rawPageId === 'number' || typeof rawPageId === 'string' ? String(rawPageId) : '',
|
|
externalUrl: asString(value['externalUrl']),
|
|
openInNewTab: value['openInNewTab'] === true,
|
|
label,
|
|
variant
|
|
};
|
|
const href = asOptionalString(value['href'] ?? value['url'] ?? value['path']);
|
|
|
|
if (href !== undefined) link.href = href;
|
|
|
|
return link;
|
|
}
|
|
|
|
function normalizeNavigationLinks(value: unknown): NavigationLink[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.map((item) => normalizeNavigationLink(item))
|
|
.filter((item): item is NavigationLink => item !== null);
|
|
}
|
|
|
|
function normalizeBreadcrumbs(value: unknown): Breadcrumb[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value.filter(isRecord).flatMap((item) => {
|
|
const label = asString(item['label']);
|
|
const href = asString(item['href']);
|
|
|
|
return label ? [{ label, href }] : [];
|
|
});
|
|
}
|
|
|
|
function normalizeHero(value: unknown): PageHero | undefined {
|
|
if (!isRecord(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const rawImpact = asString(value['type']);
|
|
const type = HERO_IMPACTS.has(rawImpact) ? (rawImpact as PageHero['type']) : 'middle';
|
|
const hero: PageHero = {
|
|
type,
|
|
ctaItems: normalizeNavigationLinks(value['ctaItems'])
|
|
};
|
|
const title = asOptionalString(value['title']);
|
|
const subtitle = asOptionalString(value['subtitle']);
|
|
const content = normalizeRichText(value['content']);
|
|
const media = normalizeMedia(value['media']);
|
|
|
|
if (title !== undefined) hero.title = title;
|
|
if (subtitle !== undefined) hero.subtitle = subtitle;
|
|
if (content.content) hero.content = content;
|
|
if (media !== undefined) hero.media = media;
|
|
|
|
return hero;
|
|
}
|
|
|
|
function unwrapBlock(value: Record<string, unknown>): Record<string, unknown> {
|
|
const data = value['data'];
|
|
if (!isRecord(data)) {
|
|
return value;
|
|
}
|
|
|
|
return {
|
|
...data,
|
|
id: value['id'] ?? data['id'],
|
|
type: value['type'] ?? data['type']
|
|
};
|
|
}
|
|
|
|
function normalizeBlock(value: unknown, index: number): PageBlock | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const block = unwrapBlock(value);
|
|
const type = asString(block['type']);
|
|
if (!type) {
|
|
return null;
|
|
}
|
|
|
|
const id = String(block['id'] ?? `${type}-${index + 1}`);
|
|
|
|
if (type === 'content') {
|
|
const columns = Array.isArray(block['columns'])
|
|
? block['columns'].filter(isRecord).map((column) => ({
|
|
content: normalizeRichText(column['content']),
|
|
ctaItems: normalizeNavigationLinks(column['ctaItems'])
|
|
}))
|
|
: [];
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
title: asString(block['title']),
|
|
subtitle: asString(block['subtitle']),
|
|
columns
|
|
} satisfies ContentBlock;
|
|
}
|
|
|
|
if (type === 'faq') {
|
|
const items = Array.isArray(block['items'])
|
|
? block['items'].filter(isRecord).map((item) => ({
|
|
question: asString(item['question']),
|
|
answer: asString(item['answer'])
|
|
}))
|
|
: [];
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
title: asString(block['title']),
|
|
subtitle: asString(block['subtitle']),
|
|
items
|
|
} satisfies FaqBlock;
|
|
}
|
|
|
|
if (type === 'media-content') {
|
|
return {
|
|
id,
|
|
type,
|
|
title: asString(block['title']),
|
|
mediaPosition: block['mediaPosition'] === 'right' ? 'right' : 'left',
|
|
media: normalizeMedia(block['media']),
|
|
content: normalizeRichText(block['content']),
|
|
ctaItems: normalizeNavigationLinks(block['ctaItems'])
|
|
} satisfies MediaContentBlock;
|
|
}
|
|
|
|
if (type === 'articles') {
|
|
const items = Array.isArray(block['items'])
|
|
? block['items'].filter(isRecord).map((item) => ({
|
|
title: asString(item['title']),
|
|
content: normalizeRichText(item['content'])
|
|
}))
|
|
: [];
|
|
const cta = normalizeNavigationLink(block['cta']);
|
|
const articlesBlock: ArticlesBlock = {
|
|
id,
|
|
type,
|
|
title: asString(block['title']),
|
|
subtitle: normalizeRichText(block['subtitle']),
|
|
items
|
|
};
|
|
|
|
if (cta !== null) articlesBlock.cta = cta;
|
|
|
|
return articlesBlock;
|
|
}
|
|
|
|
if (type === 'banner') {
|
|
const rawStyle = asString(block['style']);
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
style: BANNER_STYLES.has(rawStyle as BannerStyle) ? (rawStyle as BannerStyle) : 'info',
|
|
content: normalizeRichText(block['content']),
|
|
ctaItems: normalizeNavigationLinks(block['ctaItems'])
|
|
} satisfies BannerBlock;
|
|
}
|
|
|
|
if (type === 'media') {
|
|
return {
|
|
id,
|
|
type,
|
|
media: normalizeMedia(block['media'])
|
|
} satisfies MediaBlock;
|
|
}
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
data: block
|
|
} satisfies UnknownBlock;
|
|
}
|
|
|
|
function normalizeBlocks(value: unknown): PageBlock[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.map((block, index) => normalizeBlock(block, index))
|
|
.filter((block): block is PageBlock => block !== null);
|
|
}
|
|
|
|
function normalizeLayout(value: unknown, fallbackValue: unknown): string {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
|
|
if (isRecord(value)) {
|
|
return asString(value['type'], 'landing');
|
|
}
|
|
|
|
return asString(fallbackValue, 'landing');
|
|
}
|
|
|
|
export function normalizePage(value: unknown, request: PageRequest): PageDocument | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const hasPageIdentity =
|
|
value['id'] !== undefined ||
|
|
typeof value['path'] === 'string' ||
|
|
typeof value['title'] === 'string';
|
|
if (!hasPageIdentity) {
|
|
return null;
|
|
}
|
|
|
|
const path = normalizePath(asString(value['path'], request.path));
|
|
const finalPathSegment = path.split('/').at(-1) ?? '';
|
|
const locale = asString(value['locale'], request.locale);
|
|
const hero = normalizeHero(value['hero']);
|
|
const seoValue = isRecord(value['seo']) ? value['seo'] : {};
|
|
const title = asString(value['title'], hero?.title ?? finalPathSegment);
|
|
const page: PageDocument = {
|
|
id: String(value['id'] ?? `${locale}:${path || 'index'}`),
|
|
status: asString(value['status'], 'published'),
|
|
locale,
|
|
path,
|
|
groupKey: asString(value['groupKey'], finalPathSegment),
|
|
layout: normalizeLayout(value['layout'], value['pageType']),
|
|
title,
|
|
seo: {
|
|
title: asString(seoValue['title'] ?? value['seoTitle'], title),
|
|
description: asString(seoValue['description'] ?? value['seoDescription'])
|
|
},
|
|
breadcrumbs: normalizeBreadcrumbs(value['breadcrumbs']),
|
|
blocks: normalizeBlocks(value['blocks'] ?? value['components']),
|
|
publishedAt: asOptionalString(value['publishedAt']) ?? null,
|
|
updatedAt: asOptionalString(value['updatedAt']) ?? null
|
|
};
|
|
|
|
if (hero !== undefined) page.hero = hero;
|
|
|
|
return page;
|
|
}
|
|
|
|
function normalizeMenus(value: unknown): SiteMenu[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value.filter(isRecord).map((menu) => ({
|
|
title: asString(menu['title']).trim(),
|
|
items: normalizeNavigationLinks(menu['items'])
|
|
}));
|
|
}
|
|
|
|
export function createEmptySiteSettings(locale: string): SiteSettings {
|
|
return {
|
|
locale,
|
|
header: { menus: [], ctaItems: [] },
|
|
footer: { copyright: '', menus: [] },
|
|
legal: { privacyPath: '', termsPath: '' }
|
|
};
|
|
}
|
|
|
|
export function normalizeSiteSettings(value: unknown, locale: string): SiteSettings | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const header = isRecord(value['header']) ? value['header'] : {};
|
|
const footer = isRecord(value['footer']) ? value['footer'] : {};
|
|
const legal = isRecord(value['legal']) ? value['legal'] : {};
|
|
|
|
return {
|
|
locale: asString(value['locale'], locale),
|
|
header: {
|
|
menus: normalizeMenus(header['menus']),
|
|
ctaItems: normalizeNavigationLinks(header['ctaItems'])
|
|
},
|
|
footer: {
|
|
copyright: asString(footer['copyright']),
|
|
menus: normalizeMenus(footer['menus'])
|
|
},
|
|
legal: {
|
|
privacyPath: asString(legal['privacyPath']),
|
|
termsPath: asString(legal['termsPath'])
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizeLanguageAlternatives(value: unknown): LanguageAlternative[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value.filter(isRecord).flatMap((item) => {
|
|
const locale = asString(item['locale']);
|
|
const path = normalizePath(asString(item['path']));
|
|
|
|
return locale ? [{ locale, path }] : [];
|
|
});
|
|
}
|
|
|
|
export function normalizePageResolution(
|
|
value: unknown,
|
|
request: PageRequest
|
|
): PageResolution | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const root = isRecord(value['data']) ? value['data'] : value;
|
|
const wrappedPage = isRecord(root['page']) ? root['page'] : null;
|
|
const rawPage = wrappedPage
|
|
? {
|
|
...wrappedPage,
|
|
blocks: root['components'] ?? wrappedPage['blocks'],
|
|
hero: wrappedPage['hero'] ?? root['hero'],
|
|
breadcrumbs: root['breadcrumbs'] ?? wrappedPage['breadcrumbs']
|
|
}
|
|
: root;
|
|
const page = normalizePage(rawPage, request);
|
|
|
|
if (!page) {
|
|
return null;
|
|
}
|
|
|
|
const resolution: PageResolution = {
|
|
page,
|
|
languageAlternatives: normalizeLanguageAlternatives(root['languageAlternatives'])
|
|
};
|
|
const settings = normalizeSiteSettings(root['settings'], request.locale);
|
|
|
|
if (settings) resolution.settings = settings;
|
|
|
|
return resolution;
|
|
}
|
|
|
|
export function normalizeSitemapEntries(payload: unknown): SitemapEntry[] {
|
|
if (!isRecord(payload) || !Array.isArray(payload['items'])) {
|
|
return [];
|
|
}
|
|
|
|
return payload['items'].filter(isRecord).flatMap((item) => {
|
|
const locale = asString(item['locale']);
|
|
|
|
if (!locale) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
locale,
|
|
path: normalizePath(asString(item['path'])),
|
|
groupKey: asString(item['groupKey']),
|
|
pageType: asString(item['pageType']),
|
|
publishedAt: asOptionalString(item['publishedAt']) ?? null,
|
|
updatedAt: asOptionalString(item['updatedAt']) ?? null
|
|
}
|
|
];
|
|
});
|
|
}
|
|
|
|
export function sitemapLastPage(payload: unknown): number {
|
|
const pagination =
|
|
isRecord(payload) && isRecord(payload['pagination']) ? payload['pagination'] : {};
|
|
const lastPage = pagination['lastPage'];
|
|
|
|
return typeof lastPage === 'number' && Number.isInteger(lastPage) && lastPage > 0 ? lastPage : 1;
|
|
}
|
|
|
|
export function normalizeRoutePath(path: string): string {
|
|
return normalizePath(path);
|
|
}
|
|
|
|
export function normalizePageTeaserList(payload: unknown): PageTeaserList {
|
|
const items = normalizePageTeasers(payload);
|
|
const rawPagination =
|
|
isRecord(payload) && isRecord(payload['pagination']) ? payload['pagination'] : {};
|
|
|
|
const asPositiveInt = (value: unknown, fallback: number): number => {
|
|
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
};
|
|
|
|
return {
|
|
items,
|
|
pagination: {
|
|
page: asPositiveInt(rawPagination['page'], 1),
|
|
perPage: asPositiveInt(rawPagination['perPage'], Math.max(items.length, 1)),
|
|
total: asPositiveInt(rawPagination['total'], items.length),
|
|
lastPage: asPositiveInt(rawPagination['lastPage'], 1)
|
|
}
|
|
};
|
|
}
|
|
|
|
export function normalizePageTeasers(payload: unknown): PageTeaser[] {
|
|
if (!isRecord(payload) || !Array.isArray(payload['items'])) {
|
|
return [];
|
|
}
|
|
|
|
return payload['items'].filter(isRecord).flatMap((item) => {
|
|
const title = asString(item['title']);
|
|
const path = normalizePath(asString(item['path']));
|
|
|
|
if (!title || !path) {
|
|
return [];
|
|
}
|
|
|
|
const teaser: PageTeaser = {
|
|
id: String(item['id'] ?? path),
|
|
path,
|
|
title,
|
|
description: asString(item['description']),
|
|
publishedAt: asOptionalString(item['publishedAt']) ?? null
|
|
};
|
|
const media = normalizeMedia(item['media']);
|
|
|
|
if (media !== undefined) teaser.media = media;
|
|
|
|
return [teaser];
|
|
});
|
|
}
|