feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,56 @@
import type { ProductSummaryDto } from '@/lib/api';
import { absoluteUrl, SITE_NAME, SITE_URL } from '@/lib/seo';
export type JsonLd = Readonly<Record<string, unknown>>;
export function organizationJsonLd(): JsonLd {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
name: SITE_NAME,
url: SITE_URL,
};
}
export function breadcrumbJsonLd(items: ReadonlyArray<{ name: string; path: string }>): JsonLd {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: absoluteUrl(item.path),
})),
};
}
export function productJsonLd(product: ProductSummaryDto): JsonLd {
const image = product.images?.find((item) => item.role === 'main') ?? product.images?.[0];
return withoutUndefined({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description ?? product.seoDescription ?? undefined,
url: absoluteUrl(product.url),
image: image ? absoluteCatalogUrl(image.url) : undefined,
sku: product.slug,
});
}
export function JsonLdScript({ data }: Readonly<{ data: JsonLd | readonly JsonLd[] }>) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
/>
);
}
function absoluteCatalogUrl(value: string): string {
return URL.canParse(value) ? value : absoluteUrl(value);
}
function withoutUndefined(input: JsonLd): JsonLd {
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
}

View File

@@ -0,0 +1,58 @@
export interface StoredRedirect {
from: string;
to: string;
}
export interface RedirectRepository {
findBySourcePath(pathname: string): StoredRedirect | undefined;
}
export class EnvRedirectRepository implements RedirectRepository {
private readonly redirects: readonly StoredRedirect[];
constructor(rawJson = process.env.REDIRECTS_JSON) {
this.redirects = parseRedirects(rawJson);
}
findBySourcePath(pathname: string): StoredRedirect | undefined {
return this.redirects.find((redirect) => redirect.from === normalizePath(pathname));
}
}
export function parseRedirects(rawJson: string | undefined): readonly StoredRedirect[] {
if (rawJson === undefined || rawJson.trim() === '') return [];
try {
const parsed = JSON.parse(rawJson) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((entry) => {
if (!isRedirectEntry(entry)) return [];
const from = normalizePath(entry.from);
const to = normalizePath(entry.to);
if (!isSafeLocalPath(from) || !isSafeLocalPath(to) || from === to) return [];
return [{ from, to }];
});
} catch {
return [];
}
}
function isRedirectEntry(value: unknown): value is StoredRedirect {
return (
typeof value === 'object' &&
value !== null &&
'from' in value &&
'to' in value &&
typeof value.from === 'string' &&
typeof value.to === 'string'
);
}
function isSafeLocalPath(pathname: string): boolean {
return pathname.startsWith('/') && !pathname.startsWith('//') && !pathname.includes('://');
}
function normalizePath(pathname: string): string {
if (pathname.length > 1) return pathname.replace(/\/+$/, '');
return pathname;
}