TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
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];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user