@otl-core/cms-utils 1.0.0 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,519 @@
1
+ import { BlogCategory, ResponsiveValue, BorderConfig, ShadowConfig, ColorReference, LocalizedString, BreakpointWithBase, ResponsiveConfig, OrganizationInfo } from '@otl-core/cms-types';
2
+ import { ClassValue } from 'clsx';
3
+
4
+ /**
5
+ * A/B/n Variant Resolver
6
+ *
7
+ * Framework-agnostic logic for resolving multivariate content to a single
8
+ * variant per visitor. Uses a deterministic bucket value (float [0, 1))
9
+ * to ensure sticky variant assignment.
10
+ *
11
+ * This module handles resolving only -- analytics and testing evaluation
12
+ * happen separately downstream.
13
+ */
14
+ interface VariantConfig {
15
+ id: string;
16
+ weight: number;
17
+ }
18
+ interface MultivariatePageContent {
19
+ page: unknown;
20
+ seo: unknown;
21
+ multivariate: true;
22
+ variants: Array<{
23
+ id: string;
24
+ weight: number;
25
+ sections: unknown[];
26
+ }>;
27
+ }
28
+ interface MultivariateFormBlockData {
29
+ definition: unknown;
30
+ multivariate: true;
31
+ variants: Array<{
32
+ id: string;
33
+ weight: number;
34
+ document: unknown;
35
+ }>;
36
+ }
37
+ /**
38
+ * Given a bucket value [0, 1) and variant weights,
39
+ * deterministically select a variant.
40
+ *
41
+ * The weights don't need to sum to 100 -- they are normalized.
42
+ * A user with a given bucket will always get the same variant
43
+ * for a given weight configuration.
44
+ */
45
+ declare function selectVariant(bucket: number, variants: VariantConfig[]): string;
46
+ /**
47
+ * Check if page content is multivariate.
48
+ */
49
+ declare function isMultivariateContent(content: Record<string, unknown>): boolean;
50
+ /**
51
+ * Resolve multivariate page content to a single variant's sections.
52
+ * Returns the selected sections array and the variant ID.
53
+ */
54
+ declare function resolvePageVariant(content: MultivariatePageContent, bucket: number): {
55
+ sections: unknown[];
56
+ variantId: string;
57
+ };
58
+ /**
59
+ * Resolve multivariate blog post content to a single variant's blocks.
60
+ * Blog post layouts are blog-level (no variants). The post's content blocks
61
+ * are the variant data, returned alongside the shared layout.
62
+ *
63
+ * Mutates the content record in place: replaces "variants" with the selected "blocks".
64
+ */
65
+ declare function resolveBlogPostVariant(content: Record<string, unknown>, bucket: number): void;
66
+ /**
67
+ * Walk through sections/blocks and resolve any multivariate form block data.
68
+ * This should be called on the server before passing data to client components,
69
+ * so client-side FormBlock never has to deal with variant resolution.
70
+ */
71
+ declare function resolveFormVariantsInSections(sections: unknown[], bucket: number): unknown[];
72
+
73
+ /**
74
+ * Category utility functions
75
+ */
76
+
77
+ /**
78
+ * Tree node with children
79
+ */
80
+ interface CategoryTreeNode extends BlogCategory {
81
+ children: CategoryTreeNode[];
82
+ }
83
+ /**
84
+ * Builds a hierarchical tree from flat category array
85
+ * @param categories Flat array of categories
86
+ * @returns Array of root-level categories with nested children
87
+ */
88
+ declare function buildCategoryTree(categories: BlogCategory[]): CategoryTreeNode[];
89
+ /**
90
+ * Flattens a category tree back to a flat array
91
+ * @param tree Hierarchical category tree
92
+ * @returns Flat array of categories
93
+ */
94
+ declare function flattenCategoryTree(tree: CategoryTreeNode[]): BlogCategory[];
95
+ /**
96
+ * Gets the full path of a category (all ancestors)
97
+ * @param categoryId Category ID to get path for
98
+ * @param categories Flat array of all categories
99
+ * @param locale Locale to use for names (optional)
100
+ * @returns Array of categories from root to target
101
+ */
102
+ declare function getCategoryPath(categoryId: string, categories: BlogCategory[], locale?: string): BlogCategory[];
103
+ /**
104
+ * Gets the display name for a category in a specific locale
105
+ * @param category Category object
106
+ * @param locale Locale code (e.g., 'en', 'de-DE')
107
+ * @param fallbackLocale Fallback locale if primary not found
108
+ * @returns Display name string
109
+ */
110
+ declare function getCategoryName(category: BlogCategory, locale: string, fallbackLocale?: string): string;
111
+ /**
112
+ * Gets the slug for a category in a specific locale
113
+ * @param category Category object
114
+ * @param locale Locale code
115
+ * @param fallbackLocale Fallback locale if primary not found
116
+ * @returns Slug string
117
+ */
118
+ declare function getCategorySlug(category: BlogCategory, locale: string, fallbackLocale?: string): string;
119
+ /**
120
+ * Checks if a category is an ancestor of another category
121
+ * @param ancestorId Potential ancestor category ID
122
+ * @param descendantId Descendant category ID
123
+ * @param categories Flat array of all categories
124
+ * @returns True if ancestorId is an ancestor of descendantId
125
+ */
126
+ declare function isAncestor(ancestorId: string, descendantId: string, categories: BlogCategory[]): boolean;
127
+ /**
128
+ * Gets all descendants of a category (recursive)
129
+ * @param categoryId Parent category ID
130
+ * @param categories Flat array of all categories
131
+ * @returns Array of all descendant categories
132
+ */
133
+ declare function getDescendants(categoryId: string, categories: BlogCategory[]): BlogCategory[];
134
+ /**
135
+ * Finds a category by slug in a specific locale
136
+ * @param slug Slug to search for
137
+ * @param locale Locale code
138
+ * @param categories Flat array of all categories
139
+ * @returns Category if found, undefined otherwise
140
+ */
141
+ declare function findCategoryBySlug(slug: string, locale: string, categories: BlogCategory[]): BlogCategory | undefined;
142
+
143
+ /**
144
+ * Utility for merging Tailwind CSS class names
145
+ * Combines clsx for conditional classes with tailwind-merge to handle conflicts
146
+ */
147
+
148
+ /**
149
+ * Merge class names intelligently
150
+ * - Uses clsx for conditional classes
151
+ * - Uses tailwind-merge to deduplicate and resolve conflicts
152
+ *
153
+ * @example
154
+ * cn("px-2 py-1", condition && "bg-blue-500")
155
+ * cn({ "font-bold": isActive }, "text-lg")
156
+ */
157
+ declare function cn(...inputs: ClassValue[]): string;
158
+
159
+ /**
160
+ * CSS Resolution Utilities
161
+ * Converts CMS type system values (ColorReference, BorderConfig, ResponsiveValue)
162
+ * into CSS strings for rendering.
163
+ */
164
+
165
+ /**
166
+ * Minify CSS by removing comments, extra whitespace, and unnecessary characters
167
+ */
168
+ declare function minifyCSS(css: string): string;
169
+ /**
170
+ * Resolve a ColorReference to a CSS color string
171
+ */
172
+ declare function resolveColorToCSS(colorRef: ColorReference | undefined, target?: "background" | "foreground"): string | undefined;
173
+ /**
174
+ * Resolve multiple ColorReferences to CSS color strings
175
+ */
176
+ declare function resolveColorsToCSS<T extends Record<string, ColorReference | undefined>>(colorRefs: T): Partial<Record<keyof T, string>>;
177
+ /**
178
+ * Resolve a BorderConfig to CSS border properties
179
+ */
180
+ declare function resolveBorderToCSS(borderConfig: BorderConfig | undefined): Record<string, string> | undefined;
181
+ interface ResponsiveSpacingConfig {
182
+ border?: ResponsiveValue<BorderConfig>;
183
+ margin?: ResponsiveValue<string>;
184
+ padding?: ResponsiveValue<string>;
185
+ gap?: ResponsiveValue<string>;
186
+ shadow?: ResponsiveValue<ShadowConfig>;
187
+ }
188
+ /**
189
+ * Generate responsive CSS for spacing (margin, padding, gap, border) with media queries
190
+ */
191
+ declare function generateResponsiveSpacingCSS(className: string, config: ResponsiveSpacingConfig): string | null;
192
+ /**
193
+ * Check if a ResponsiveSpacingConfig has any margin values set
194
+ */
195
+ declare function hasAnyMargin(config: ResponsiveSpacingConfig): boolean;
196
+ /**
197
+ * Map animation timing name to CSS timing function
198
+ */
199
+ declare function getAnimationTimingFunction(timing?: string): string;
200
+
201
+ /**
202
+ * Get localized string with proper fallback logic
203
+ * 1. Try user's preferred locale (from cookie or browser)
204
+ * 2. Try default locale (from deployment config)
205
+ * 3. Try 'en' as ultimate fallback
206
+ * 4. Return first available value as last resort
207
+ */
208
+ declare function getLocalizedString(value: string | LocalizedString | null | undefined, options?: {
209
+ preferredLocale?: string | null;
210
+ defaultLocale?: string;
211
+ supportedLocales?: string[];
212
+ }): string;
213
+ /**
214
+ * Get all available locales from a localized string
215
+ */
216
+ declare function getAvailableLocales(value: string | LocalizedString): string[];
217
+ /**
218
+ * Check if a value is a localized string object
219
+ */
220
+ declare function isLocalizedString(value: unknown): value is LocalizedString;
221
+ /**
222
+ * Detect locale from various sources (URL, browser, config)
223
+ * Priority: URL param > path segment > browser language > default
224
+ * Note: This function requires browser environment
225
+ */
226
+ declare function detectLocale(defaultLocale?: string, supportedLocales?: string[]): string;
227
+ /**
228
+ * Format locale for HTML lang attribute (e.g., 'en-US')
229
+ */
230
+ declare function formatLocaleForHtml(locale: string): string;
231
+
232
+ /**
233
+ * Utility functions for working with responsive values
234
+ */
235
+
236
+ /** A responsive value where every breakpoint -- including base -- is optional. */
237
+ type NormalizedResponsiveValue<T> = {
238
+ base?: T;
239
+ sm?: T;
240
+ md?: T;
241
+ lg?: T;
242
+ xl?: T;
243
+ "2xl"?: T;
244
+ };
245
+ /**
246
+ * Normalize an optional `ResponsiveValue<T>` into a flat object where every
247
+ * breakpoint key is optional. Useful for CSS generation loops.
248
+ *
249
+ * - `undefined` -> `{}`
250
+ * - `"8px"` -> `{ base: "8px" }`
251
+ * - `{ base: "8px", md: "16px" }` -> same
252
+ */
253
+ declare function normalizeResponsiveValue<T>(value: ResponsiveValue<T> | undefined): NormalizedResponsiveValue<T>;
254
+ /**
255
+ * Type guard to check if a value is a ResponsiveConfig
256
+ * Requires the object to have a "base" property
257
+ */
258
+ declare function isResponsiveConfig<T>(value: ResponsiveValue<T>): value is ResponsiveConfig<T>;
259
+ /**
260
+ * Get value for a specific breakpoint with fallback to base
261
+ */
262
+ declare function getBreakpointValue<T>(value: ResponsiveValue<T>, breakpoint: BreakpointWithBase): T;
263
+ /**
264
+ * Get all defined breakpoints in a responsive value
265
+ */
266
+ declare function getDefinedBreakpoints<T>(value: ResponsiveValue<T>): BreakpointWithBase[];
267
+ /**
268
+ * Convert a single value or responsive value to a ResponsiveConfig
269
+ */
270
+ declare function toResponsiveConfig<T>(fields: {
271
+ base: T;
272
+ sm?: T;
273
+ md?: T;
274
+ lg?: T;
275
+ xl?: T;
276
+ "2xl"?: T;
277
+ }): ResponsiveConfig<T>;
278
+ /**
279
+ * Convert a ResponsiveConfig to flat breakpoint fields
280
+ */
281
+ declare function fromResponsiveConfig<T>(value: ResponsiveValue<T>): {
282
+ base: T;
283
+ sm?: T;
284
+ md?: T;
285
+ lg?: T;
286
+ xl?: T;
287
+ "2xl"?: T;
288
+ };
289
+
290
+ /**
291
+ * Schedule Resolver
292
+ * Framework-agnostic content scheduling utilities
293
+ */
294
+ /**
295
+ * Check if content is currently visible based on schedule metadata.
296
+ * Returns true if the content should be shown to visitors.
297
+ *
298
+ * @param publishAt - ISO 8601 timestamp when content should become visible
299
+ * @param expiresAt - ISO 8601 timestamp when content should stop being visible
300
+ * @param now - Optional current time (defaults to new Date())
301
+ * @returns true if content is visible, false otherwise
302
+ */
303
+ declare function isContentVisible(publishAt: string | null | undefined, expiresAt: string | null | undefined, now?: Date): boolean;
304
+ /**
305
+ * Filter a list of items based on schedule metadata.
306
+ * Each item must have optional publish_at and expires_at fields.
307
+ *
308
+ * @param items - Array of items with schedule metadata
309
+ * @param now - Optional current time (defaults to new Date())
310
+ * @returns Filtered array containing only currently visible items
311
+ */
312
+ declare function filterScheduledContent<T extends {
313
+ publish_at?: string;
314
+ expires_at?: string;
315
+ }>(items: T[], now?: Date): T[];
316
+
317
+ /**
318
+ * Style Utilities
319
+ * Helpers to convert CMS config values to CSS strings and Tailwind classes.
320
+ * For CSS resolution utilities (resolveColorToCSS, resolveBorderToCSS, etc.)
321
+ * see css.utils.ts.
322
+ */
323
+
324
+ /**
325
+ * Convert border config to CSS string
326
+ */
327
+ declare function borderToStyle(border: ResponsiveValue<BorderConfig> | undefined): string;
328
+ /**
329
+ * Format shadow config to CSS box-shadow value
330
+ */
331
+ declare function formatShadow(shadow: ShadowConfig | undefined): string;
332
+ /**
333
+ * Convert padding config to Tailwind class
334
+ */
335
+ declare function paddingToClass(padding?: string): string;
336
+ /**
337
+ * Convert padding top config to Tailwind class
338
+ */
339
+ declare function paddingTopToClass(paddingTop?: string): string;
340
+ /**
341
+ * Convert padding bottom config to Tailwind class
342
+ */
343
+ declare function paddingBottomToClass(paddingBottom?: string): string;
344
+ /**
345
+ * Convert spacing config to Tailwind class
346
+ */
347
+ declare function spacingToClass(spacing?: string): string;
348
+ /**
349
+ * Convert max-width config to Tailwind class
350
+ */
351
+ declare function widthToClass(width?: string): string;
352
+ /**
353
+ * Convert color config to inline style object
354
+ */
355
+ declare function colorToStyle(color?: string): {
356
+ backgroundColor?: string;
357
+ };
358
+ /**
359
+ * Convert gap config to Tailwind class
360
+ */
361
+ declare function gapToClass(gap?: number): string;
362
+
363
+ /**
364
+ * Password Protection Resolution Utilities
365
+ *
366
+ * Framework-agnostic utilities for handling password-protected content.
367
+ * These utilities determine if content is password-protected and filter
368
+ * out protected content from lists (e.g., blog post listings).
369
+ *
370
+ * According to the architecture:
371
+ * - Backend returns ALL content with metadata
372
+ * - Engine/frontend decides visibility at render time
373
+ * - Password protection is checked on the client side
374
+ */
375
+ /**
376
+ * Check if content is password-protected
377
+ * @param password_protected - Whether the content requires a password
378
+ * @returns True if content is password-protected, false otherwise
379
+ */
380
+ declare function isPasswordProtected(password_protected?: boolean): boolean;
381
+ /**
382
+ * Filter password-protected content from a list
383
+ * Useful for listings like blog posts where we don't want to show protected items
384
+ * @param items - Array of items with password_protected metadata
385
+ * @returns Filtered array with only non-protected items
386
+ */
387
+ declare function filterPasswordProtectedContent<T extends {
388
+ password_protected?: boolean;
389
+ }>(items: T[]): T[];
390
+
391
+ /**
392
+ * JSON-LD structured data generation utilities.
393
+ *
394
+ * Produces a Schema.org @graph for pages, blog posts, and the overall site,
395
+ * using data already available in the CMS (website config, page/post metadata,
396
+ * organization config).
397
+ */
398
+
399
+ interface JsonLdInput {
400
+ /** Canonical site origin, e.g. "https://example.com" */
401
+ siteUrl: string;
402
+ /** Current page path, e.g. "/about" */
403
+ path: string;
404
+ /** Current locale code */
405
+ locale: string;
406
+ /** Human-readable site name */
407
+ siteName: string;
408
+ /** Optional site description */
409
+ siteDescription?: string;
410
+ /** Organization config from WebsiteConfig */
411
+ organization?: OrganizationInfo;
412
+ /** Page-specific data (mutually exclusive with blogPost) */
413
+ page?: {
414
+ title: string;
415
+ description?: string;
416
+ schemaType?: string;
417
+ datePublished?: string;
418
+ dateModified?: string;
419
+ ogImage?: string;
420
+ };
421
+ /** Blog post data (mutually exclusive with page) */
422
+ blogPost?: {
423
+ title: string;
424
+ excerpt?: string;
425
+ datePublished: string;
426
+ dateModified?: string;
427
+ authorName?: string;
428
+ authorUrl?: string;
429
+ categories?: string[];
430
+ featuredImage?: string;
431
+ };
432
+ /** Explicit breadcrumb items; auto-built from path if omitted */
433
+ breadcrumbs?: {
434
+ name: string;
435
+ path: string;
436
+ }[];
437
+ }
438
+ /**
439
+ * Derive breadcrumb items from a URL path.
440
+ *
441
+ * "/" -> [{ name: "Home", path: "/" }]
442
+ * "/about" -> [{ name: "Home", path: "/" }, { name: "About", path: "/about" }]
443
+ * "/blog/my-post" -> [Home, Blog, My Post]
444
+ */
445
+ declare function buildBreadcrumbs(path: string, pageTitle: string): {
446
+ name: string;
447
+ path: string;
448
+ }[];
449
+ /**
450
+ * Generate a JSON-LD `@graph` object for the given page/post.
451
+ *
452
+ * When an override is provided externally (structured_data_override), callers
453
+ * should use the override directly instead of calling this function.
454
+ */
455
+ declare function generateJsonLd(input: JsonLdInput): Record<string, unknown>;
456
+
457
+ /**
458
+ * SEO utility functions for OTL CMS.
459
+ *
460
+ * These helpers extract common metadata from deployment configs and are used
461
+ * by both the engine's `generateMetadata` and component-level code.
462
+ *
463
+ * They are environment-agnostic: callers pass an explicit `siteUrlOverride`
464
+ * (e.g. from `process.env.NEXT_PUBLIC_SITE_URL`) instead of reading env vars
465
+ * directly, keeping the package framework-independent.
466
+ */
467
+ /**
468
+ * Derive the public site origin (no trailing slash).
469
+ *
470
+ * Priority:
471
+ * 1. `siteUrlOverride` (e.g. env var)
472
+ * 2. First custom domain from deployment config
473
+ * 3. Subdomain-based OTL Studio URL
474
+ */
475
+ declare function deriveSiteUrl(configs: Record<string, unknown>, siteUrlOverride?: string): string;
476
+ /** Extract the localized site name from configs. */
477
+ declare function deriveSiteName(configs: Record<string, unknown>, locale: string): string;
478
+ /** Extract the localized site description from configs. */
479
+ declare function deriveSiteDescription(configs: Record<string, unknown>, locale: string): string | undefined;
480
+ /**
481
+ * Detect a locale prefix from the first URL segment.
482
+ *
483
+ * Returns `[locale, pathWithoutPrefix]`. If the first segment is not a
484
+ * recognised locale, the deployment's default locale and the original path
485
+ * are returned unchanged.
486
+ */
487
+ declare function detectLocaleFromSegments(segments: string[], fullPath: string, supportedLocales: string[], defaultLocale: string): [string, string];
488
+ /**
489
+ * Convert a short locale code to the `og:locale` format.
490
+ *
491
+ * "en" -> "en_US", "de" -> "de_DE", etc.
492
+ * If the code is already in `xx_XX` form it is returned as-is.
493
+ */
494
+ declare function localeToOgFormat(locale: string): string;
495
+ /**
496
+ * Build hreflang alternate URLs for multi-locale support.
497
+ *
498
+ * Uses the locale-prefix pattern: the default locale maps to the bare path
499
+ * while other locales are prefixed (`/de/path`). An `x-default` entry
500
+ * points to the un-prefixed URL.
501
+ *
502
+ * Returns `undefined` when there is only a single locale (hreflang would be
503
+ * pointless).
504
+ */
505
+ declare function buildHreflangAlternates(siteUrl: string, path: string, supportedLocales: string[], defaultLocale: string): Record<string, string> | undefined;
506
+ interface PaginationInfo {
507
+ page: number;
508
+ totalPages: number;
509
+ hasNext: boolean;
510
+ hasPrev: boolean;
511
+ }
512
+ /**
513
+ * Walk through enriched layout sections/blocks and return the first
514
+ * pagination object found (from a `blog-post-list` or `blog-pagination`
515
+ * block's `data.pagination`).
516
+ */
517
+ declare function extractPaginationFromLayout(layout: unknown[] | undefined): PaginationInfo | null;
518
+
519
+ export { type CategoryTreeNode, type JsonLdInput, type MultivariateFormBlockData, type MultivariatePageContent, type NormalizedResponsiveValue, type PaginationInfo, type ResponsiveSpacingConfig, type VariantConfig, borderToStyle, buildBreadcrumbs, buildCategoryTree, buildHreflangAlternates, cn, colorToStyle, deriveSiteDescription, deriveSiteName, deriveSiteUrl, detectLocale, detectLocaleFromSegments, extractPaginationFromLayout, filterPasswordProtectedContent, filterScheduledContent, findCategoryBySlug, flattenCategoryTree, formatLocaleForHtml, formatShadow, fromResponsiveConfig, gapToClass, generateJsonLd, generateResponsiveSpacingCSS, getAnimationTimingFunction, getAvailableLocales, getBreakpointValue, getCategoryName, getCategoryPath, getCategorySlug, getDefinedBreakpoints, getDescendants, getLocalizedString, hasAnyMargin, isAncestor, isContentVisible, isLocalizedString, isMultivariateContent, isPasswordProtected, isResponsiveConfig, localeToOgFormat, minifyCSS, normalizeResponsiveValue, paddingBottomToClass, paddingToClass, paddingTopToClass, resolveBlogPostVariant, resolveBorderToCSS, resolveColorToCSS, resolveColorsToCSS, resolveFormVariantsInSections, resolvePageVariant, selectVariant, spacingToClass, toResponsiveConfig, widthToClass };