TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
+508
View File
@@ -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];
});
}