@flamingo-stack/openframe-frontend-core 0.0.254 → 0.0.255-snapshot.20260611213621

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.
Files changed (173) hide show
  1. package/dist/{chunk-ELF4OOHC.cjs → chunk-3DC6MHW6.cjs} +33 -33
  2. package/dist/{chunk-ELF4OOHC.cjs.map → chunk-3DC6MHW6.cjs.map} +1 -1
  3. package/dist/{chunk-QCRQNKRO.js → chunk-46DHPQY6.js} +4 -4
  4. package/dist/{chunk-ZITMX2IQ.cjs → chunk-6F5CGBVT.cjs} +745 -767
  5. package/dist/chunk-6F5CGBVT.cjs.map +1 -0
  6. package/dist/{chunk-4RDJZIHW.js → chunk-6GBA6YJN.js} +3 -3
  7. package/dist/{chunk-4HHYUEMD.js → chunk-7RLV3U5C.js} +2 -2
  8. package/dist/{chunk-SGJBUL2Y.cjs → chunk-7VMEOFEA.cjs} +45 -2
  9. package/dist/chunk-7VMEOFEA.cjs.map +1 -0
  10. package/dist/{chunk-TSY72CQ6.cjs → chunk-CMWRZECW.cjs} +77 -42
  11. package/dist/chunk-CMWRZECW.cjs.map +1 -0
  12. package/dist/{chunk-CAPVFI5A.js → chunk-HCMVLEC3.js} +4 -4
  13. package/dist/{chunk-LZL6P7SN.cjs → chunk-IG4QRNYZ.cjs} +10 -10
  14. package/dist/{chunk-LZL6P7SN.cjs.map → chunk-IG4QRNYZ.cjs.map} +1 -1
  15. package/dist/{chunk-HYJ5IV4Z.cjs → chunk-ISALYOFX.cjs} +19 -19
  16. package/dist/{chunk-HYJ5IV4Z.cjs.map → chunk-ISALYOFX.cjs.map} +1 -1
  17. package/dist/{chunk-MKVKVJQQ.js → chunk-J3QMPOS6.js} +5 -4
  18. package/dist/chunk-J3QMPOS6.js.map +1 -0
  19. package/dist/{chunk-BE472J6X.js → chunk-JK25REDL.js} +48 -13
  20. package/dist/chunk-JK25REDL.js.map +1 -0
  21. package/dist/{chunk-Z4IBEIWD.cjs → chunk-LGLPNWS6.cjs} +3 -3
  22. package/dist/{chunk-Z4IBEIWD.cjs.map → chunk-LGLPNWS6.cjs.map} +1 -1
  23. package/dist/{chunk-VAUI3CND.js → chunk-NR535XIY.js} +44 -1
  24. package/dist/chunk-NR535XIY.js.map +1 -0
  25. package/dist/{chunk-CZR7ARBA.js → chunk-PHWQLKVE.js} +6 -2
  26. package/dist/chunk-PHWQLKVE.js.map +1 -0
  27. package/dist/{chunk-OZ3GH6OQ.cjs → chunk-PXXS27EE.cjs} +6 -2
  28. package/dist/chunk-PXXS27EE.cjs.map +1 -0
  29. package/dist/{chunk-2FCXJTOV.js → chunk-QXSRQVRO.js} +3 -1
  30. package/dist/chunk-QXSRQVRO.js.map +1 -0
  31. package/dist/{chunk-NP5U35FA.js → chunk-S7IX7YU4.js} +198 -220
  32. package/dist/chunk-S7IX7YU4.js.map +1 -0
  33. package/dist/{chunk-6LK4VEXE.cjs → chunk-TSZBJVYK.cjs} +35 -31
  34. package/dist/chunk-TSZBJVYK.cjs.map +1 -0
  35. package/dist/{chunk-TU26IZPO.cjs → chunk-U6UNAC32.cjs} +3 -1
  36. package/dist/chunk-U6UNAC32.cjs.map +1 -0
  37. package/dist/{chunk-265KKBV2.js → chunk-V4IIBNTA.js} +3 -3
  38. package/dist/chunk-V4IIBNTA.js.map +1 -0
  39. package/dist/{chunk-65JN25VM.cjs → chunk-V4Q5PIBV.cjs} +30 -30
  40. package/dist/{chunk-65JN25VM.cjs.map → chunk-V4Q5PIBV.cjs.map} +1 -1
  41. package/dist/{chunk-LKI3YCAX.js → chunk-V5L4D454.js} +10 -6
  42. package/dist/chunk-V5L4D454.js.map +1 -0
  43. package/dist/{chunk-3TDLFCCS.cjs → chunk-ZJP2BB3E.cjs} +6 -5
  44. package/dist/chunk-ZJP2BB3E.cjs.map +1 -0
  45. package/dist/components/chat/entity-cards/entity-author-card.d.ts +13 -9
  46. package/dist/components/chat/entity-cards/entity-author-card.d.ts.map +1 -1
  47. package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
  48. package/dist/components/chat/index.cjs +10 -8
  49. package/dist/components/chat/index.cjs.map +1 -1
  50. package/dist/components/chat/index.d.ts +1 -0
  51. package/dist/components/chat/index.d.ts.map +1 -1
  52. package/dist/components/chat/index.js +9 -7
  53. package/dist/components/chat/types/entities/onboarding-guide.d.ts +2 -7
  54. package/dist/components/chat/types/entities/onboarding-guide.d.ts.map +1 -1
  55. package/dist/components/contact/index.cjs +9 -9
  56. package/dist/components/contact/index.js +8 -8
  57. package/dist/components/faq/faq-section.d.ts.map +1 -1
  58. package/dist/components/faq/index.cjs +3 -3
  59. package/dist/components/faq/index.js +2 -2
  60. package/dist/components/features/index.cjs +8 -8
  61. package/dist/components/features/index.js +7 -7
  62. package/dist/components/features/notifications/notification-drawer.d.ts.map +1 -1
  63. package/dist/components/icons/index.cjs +2 -2
  64. package/dist/components/icons/index.js +1 -1
  65. package/dist/components/index.cjs +167 -157
  66. package/dist/components/index.cjs.map +1 -1
  67. package/dist/components/index.js +55 -45
  68. package/dist/components/index.js.map +1 -1
  69. package/dist/components/navigation/app-layout-drawer.d.ts +1 -1
  70. package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -1
  71. package/dist/components/navigation/index.cjs +8 -8
  72. package/dist/components/navigation/index.js +7 -7
  73. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  74. package/dist/components/onboarding-guides/index.cjs +25 -23
  75. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  76. package/dist/components/onboarding-guides/index.js +6 -4
  77. package/dist/components/onboarding-guides/index.js.map +1 -1
  78. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts +4 -1
  79. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  80. package/dist/components/related-content/index.cjs +11 -9
  81. package/dist/components/related-content/index.cjs.map +1 -1
  82. package/dist/components/related-content/index.d.ts +1 -1
  83. package/dist/components/related-content/index.d.ts.map +1 -1
  84. package/dist/components/related-content/index.js +10 -8
  85. package/dist/components/related-content/related-content-section.d.ts +16 -1
  86. package/dist/components/related-content/related-content-section.d.ts.map +1 -1
  87. package/dist/components/shared/product-release/release-detail-page.d.ts +4 -1
  88. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  89. package/dist/components/social-icon-row.d.ts +17 -4
  90. package/dist/components/social-icon-row.d.ts.map +1 -1
  91. package/dist/components/tickets/index.cjs +81 -81
  92. package/dist/components/tickets/index.js +9 -9
  93. package/dist/components/ui/button/button.d.ts.map +1 -1
  94. package/dist/components/ui/drawer.d.ts +6 -2
  95. package/dist/components/ui/drawer.d.ts.map +1 -1
  96. package/dist/components/ui/file-manager/index.cjs +20 -20
  97. package/dist/components/ui/file-manager/index.js +1 -1
  98. package/dist/components/ui/index.cjs +10 -8
  99. package/dist/components/ui/index.cjs.map +1 -1
  100. package/dist/components/ui/index.js +9 -7
  101. package/dist/hooks/index.cjs +4 -4
  102. package/dist/hooks/index.js +3 -3
  103. package/dist/index.cjs +13 -9
  104. package/dist/index.cjs.map +1 -1
  105. package/dist/index.js +17 -13
  106. package/dist/nats/index.cjs +2 -2
  107. package/dist/nats/index.js +1 -1
  108. package/dist/nats/nats-provider.d.ts.map +1 -1
  109. package/dist/nats/nats.d.ts.map +1 -1
  110. package/dist/types/blog.d.ts +7 -0
  111. package/dist/types/blog.d.ts.map +1 -1
  112. package/dist/types/entity-author.d.ts +18 -0
  113. package/dist/types/entity-author.d.ts.map +1 -0
  114. package/dist/types/index.cjs.map +1 -1
  115. package/dist/types/index.d.ts +1 -0
  116. package/dist/types/index.d.ts.map +1 -1
  117. package/dist/types/index.js.map +1 -1
  118. package/dist/types/navigation.d.ts +1 -0
  119. package/dist/types/navigation.d.ts.map +1 -1
  120. package/dist/types/product-release.d.ts +2 -7
  121. package/dist/types/product-release.d.ts.map +1 -1
  122. package/dist/utils/common.d.ts +9 -0
  123. package/dist/utils/common.d.ts.map +1 -1
  124. package/dist/utils/index.cjs +6 -2
  125. package/dist/utils/index.cjs.map +1 -1
  126. package/dist/utils/index.d.ts +1 -1
  127. package/dist/utils/index.d.ts.map +1 -1
  128. package/dist/utils/index.js +6 -3
  129. package/dist/utils/index.js.map +1 -1
  130. package/package.json +1 -1
  131. package/src/components/chat/entity-cards/entity-author-card.tsx +30 -11
  132. package/src/components/chat/hooks/use-nats-dialog-subscription.ts +6 -4
  133. package/src/components/chat/index.ts +1 -0
  134. package/src/components/chat/types/entities/onboarding-guide.ts +3 -7
  135. package/src/components/faq/faq-section.tsx +4 -1
  136. package/src/components/features/notifications/notification-drawer.tsx +16 -13
  137. package/src/components/icons/x-logo.tsx +2 -2
  138. package/src/components/navigation/navigation-sidebar-item.tsx +14 -1
  139. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +5 -0
  140. package/src/components/related-content/index.ts +1 -0
  141. package/src/components/related-content/related-content-section.tsx +84 -8
  142. package/src/components/shared/product-release/release-detail-page.tsx +18 -21
  143. package/src/components/social-icon-row.tsx +51 -22
  144. package/src/components/ui/button/button.tsx +7 -0
  145. package/src/components/ui/drawer.tsx +10 -3
  146. package/src/nats/nats-provider.tsx +1 -0
  147. package/src/nats/nats.ts +5 -1
  148. package/src/types/blog.ts +9 -1
  149. package/src/types/entity-author.ts +17 -0
  150. package/src/types/index.ts +1 -0
  151. package/src/types/navigation.ts +1 -0
  152. package/src/types/product-release.ts +2 -7
  153. package/src/utils/common.ts +12 -0
  154. package/src/utils/index.ts +1 -1
  155. package/dist/chunk-265KKBV2.js.map +0 -1
  156. package/dist/chunk-2FCXJTOV.js.map +0 -1
  157. package/dist/chunk-3TDLFCCS.cjs.map +0 -1
  158. package/dist/chunk-6LK4VEXE.cjs.map +0 -1
  159. package/dist/chunk-BE472J6X.js.map +0 -1
  160. package/dist/chunk-CZR7ARBA.js.map +0 -1
  161. package/dist/chunk-LKI3YCAX.js.map +0 -1
  162. package/dist/chunk-MKVKVJQQ.js.map +0 -1
  163. package/dist/chunk-NP5U35FA.js.map +0 -1
  164. package/dist/chunk-OZ3GH6OQ.cjs.map +0 -1
  165. package/dist/chunk-SGJBUL2Y.cjs.map +0 -1
  166. package/dist/chunk-TSY72CQ6.cjs.map +0 -1
  167. package/dist/chunk-TU26IZPO.cjs.map +0 -1
  168. package/dist/chunk-VAUI3CND.js.map +0 -1
  169. package/dist/chunk-ZITMX2IQ.cjs.map +0 -1
  170. /package/dist/{chunk-QCRQNKRO.js.map → chunk-46DHPQY6.js.map} +0 -0
  171. /package/dist/{chunk-4RDJZIHW.js.map → chunk-6GBA6YJN.js.map} +0 -0
  172. /package/dist/{chunk-4HHYUEMD.js.map → chunk-7RLV3U5C.js.map} +0 -0
  173. /package/dist/{chunk-CAPVFI5A.js.map → chunk-HCMVLEC3.js.map} +0 -0
@@ -1,18 +1,28 @@
1
1
  "use client"
2
2
 
3
3
  import { Button } from './ui/button';
4
- import { GitHubIcon, RedditIcon, XLogo, LinkedInIcon, LumaIcon, WhatsAppIcon, GlobeIcon, MessageCircleIcon, TelegramIcon, YouTubeIcon, InstagramIcon, FacebookIcon, SlackIcon } from './icons';
4
+ import { GitHubIcon, RedditIcon, XLogo, LinkedInIcon, LumaIcon, WhatsAppIcon, GlobeIcon, MessageCircleIcon, TelegramIcon, YouTubeIcon, InstagramIcon, FacebookIcon, SlackIcon, CopyIcon } from './icons';
5
5
 
6
- interface SocialLink {
6
+ /** Exactly ONE of `href` (anchor, target _blank) or `onClick` (action
7
+ * button — share popups via window.open inside the click gesture,
8
+ * copy-to-clipboard) — the discriminated union makes a dead no-action
9
+ * entry unrepresentable. */
10
+ type SocialLink = {
7
11
  platform: string;
8
- href: string;
9
12
  label?: string;
10
- }
13
+ } & (
14
+ | { href: string; onClick?: never }
15
+ | { onClick: () => void; href?: never }
16
+ );
11
17
 
12
18
  interface SocialIconRowProps {
13
19
  className?: string;
14
20
  links?: SocialLink[];
15
21
  variant?: "accent" | "outline" | "transparent" | "destructive" | null | undefined;
22
+ /** Natural icon-button sizing (w-fit container, no flex-1 stretch) for
23
+ * page-level rows. Default false: buttons stretch across the container —
24
+ * the original card-width behavior (TMCG member cards, footers). */
25
+ compact?: boolean;
16
26
  }
17
27
 
18
28
  const defaultLinks: SocialLink[] = [
@@ -57,32 +67,51 @@ function renderSocialIcon(platform: string) {
57
67
  case 'facebook':
58
68
  case 'fb':
59
69
  return <FacebookIcon className="w-5 h-5" />;
70
+ case 'copy':
71
+ // CopyIcon's default fill is grey and would mismatch its row-mates —
72
+ // force the themed foreground via the ODS token (white on the dark
73
+ // theme, tracking the theme unlike the literal the reddit/slack cases
74
+ // still carry).
75
+ return <CopyIcon className="w-5 h-5" color="var(--ods-text-primary)" />;
60
76
  default:
61
77
  return <GlobeIcon className="w-5 h-5" />;
62
78
  }
63
79
  }
64
80
 
65
- export function SocialIconRow({ className = '', links = defaultLinks, variant = 'outline' }: SocialIconRowProps) {
81
+ export function SocialIconRow({ className = '', links = defaultLinks, variant = 'outline', compact = false }: SocialIconRowProps) {
66
82
  return (
67
- <div className={`flex flex-row gap-3 w-full ${className}`}>
68
- {links.map((link, index) => (
69
- <Button
70
- key={index}
71
- asChild
72
- variant={variant}
73
- size="icon"
74
- className="flex-1"
75
- >
76
- <a
83
+ <div className={`flex flex-row gap-3 ${compact ? 'w-fit' : 'w-full'} ${className}`}>
84
+ {links.map((link, index) => {
85
+ const ariaLabel = link.label || link.platform;
86
+ return link.onClick ? (
87
+ <Button
88
+ key={index}
89
+ type="button"
90
+ variant={variant}
91
+ size="icon"
92
+ className={compact ? undefined : 'flex-1'}
93
+ aria-label={ariaLabel}
94
+ onClick={link.onClick}
95
+ >
96
+ {renderSocialIcon(link.platform)}
97
+ </Button>
98
+ ) : (
99
+ // Props-based linking — Button renders the anchor itself
100
+ // (openInNewTab carries target="_blank" + rel="noopener noreferrer");
101
+ // no asChild/<a> nesting.
102
+ <Button
103
+ key={index}
104
+ variant={variant}
105
+ size="icon"
106
+ className={compact ? undefined : 'flex-1'}
77
107
  href={link.href}
78
- target="_blank"
79
- rel="noopener noreferrer"
80
- aria-label={link.label || link.platform}
108
+ openInNewTab
109
+ aria-label={ariaLabel}
81
110
  >
82
111
  {renderSocialIcon(link.platform)}
83
- </a>
84
- </Button>
85
- ))}
112
+ </Button>
113
+ );
114
+ })}
86
115
  </div>
87
116
  );
88
- }
117
+ }
@@ -235,6 +235,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
235
235
  prefetch={prefetch}
236
236
  target={splitAnchor.target}
237
237
  rel={splitAnchor.rel}
238
+ // The Link branches don't spread {...props} (button attrs don't
239
+ // belong on an anchor), but the accessible name MUST survive —
240
+ // icon-only link buttons have no text content.
241
+ aria-label={props['aria-label']}
238
242
  aria-disabled={isDisabled || undefined}
239
243
  tabIndex={isDisabled ? -1 : undefined}
240
244
  className={cn(shellClasses, isDisabled && "pointer-events-none")}
@@ -300,6 +304,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
300
304
  prefetch={prefetch}
301
305
  target={anchor.target}
302
306
  rel={anchor.rel}
307
+ // See the splitAnchor branch — keep the accessible name on the
308
+ // anchor render (icon-only link buttons have no text content).
309
+ aria-label={props['aria-label']}
303
310
  aria-disabled={isDisabled || undefined}
304
311
  tabIndex={isDisabled ? -1 : undefined}
305
312
  className={cn(classes, isDisabled && "pointer-events-none")}
@@ -464,10 +464,16 @@ const DrawerHeader = ({
464
464
  )
465
465
  DrawerHeader.displayName = "DrawerHeader"
466
466
 
467
+ interface DrawerTitleProps
468
+ extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> {
469
+ /** Optional header actions rendered between the title and the close button. */
470
+ actions?: React.ReactNode
471
+ }
472
+
467
473
  const DrawerTitle = React.forwardRef<
468
474
  React.ComponentRef<typeof DialogPrimitive.Title>,
469
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
470
- >(({ className, children, ...props }, ref) => (
475
+ DrawerTitleProps
476
+ >(({ className, children, actions, ...props }, ref) => (
471
477
  <div className="flex items-start gap-4">
472
478
  <DialogPrimitive.Title
473
479
  ref={ref}
@@ -479,6 +485,7 @@ const DrawerTitle = React.forwardRef<
479
485
  >
480
486
  {children}
481
487
  </DialogPrimitive.Title>
488
+ {actions}
482
489
  <DialogPrimitive.Close className="shrink-0 rounded-sm text-ods-text-secondary transition-colors hover:text-ods-text-primary outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0">
483
490
  <X className="size-6" />
484
491
  <span className="sr-only">Close</span>
@@ -538,4 +545,4 @@ export {
538
545
  DrawerFooter,
539
546
  }
540
547
 
541
- export type { DrawerContentProps, DrawerSide }
548
+ export type { DrawerContentProps, DrawerSide, DrawerTitleProps }
@@ -101,6 +101,7 @@ export function NatsProvider({
101
101
  backoff: reconnectionBackoffRef.current,
102
102
  getFreshUrl: () => getWsUrlRef.current(),
103
103
  onStatusChange: (newStatus) => {
104
+ if (newStatus === 'error') return
104
105
  setStatus(newStatus)
105
106
  if (newStatus === 'connected') {
106
107
  if (hadConnectionBeforeRef.current) {
package/src/nats/nats.ts CHANGED
@@ -327,7 +327,11 @@ export function createNatsClient(options: NatsClientOptions): NatsClient {
327
327
  } catch (e) {
328
328
  if (!signal.aborted) {
329
329
  emitStatus({ status: 'error', data: e })
330
- nc = null
330
+
331
+ if (nc === conn && conn.isClosed()) {
332
+ nc = null
333
+ emitStatus({ status: 'closed' })
334
+ }
331
335
  }
332
336
  }
333
337
  })().catch(() => {
package/src/types/blog.ts CHANGED
@@ -4,12 +4,20 @@ import type { UserProfile } from "./user"
4
4
  export type Platform = string;
5
5
  export type BlogStatus = 'draft' | 'ai_drafted' | 'published' | 'ai_published' | 'scheduled' | 'archived';
6
6
 
7
- // Author interface for blog posts
7
+ // Author interface for blog posts (camelCase — the blog contract's own
8
+ // shape; detail-row authors elsewhere use the shared EntityAuthor).
8
9
  export interface BlogAuthor {
9
10
  id: number;
10
11
  name?: string;
11
12
  avatar?: string;
12
13
  bio?: string;
14
+ jobTitle?: string;
15
+ /** Public author-page slug — present only when the hub's public-author
16
+ * gate passes (is_real profiles only). */
17
+ slug?: string;
18
+ /** Author's LinkedIn from profile_social_links — Article schema sameAs
19
+ * + the author.url fallback for slug-less authors. */
20
+ linkedinUrl?: string;
13
21
  }
14
22
 
15
23
  // Database Models
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Hydrated content author — THE shared author shape attached to detail rows
3
+ * by the hub's `hydrateAuthor` (product releases, onboarding guides) and the
4
+ * per-entity author joins. One type for every author surface: metadata-grid
5
+ * author cells, bylines, author-page hrefs.
6
+ */
7
+ export interface EntityAuthor {
8
+ id?: string
9
+ full_name: string | null
10
+ avatar_url: string | null
11
+ job_title?: string | null
12
+ /** Public author-page slug — present only when the hub's public-author
13
+ * gate passes (`is_real` profiles only). Feeds /authors/[slug] links. */
14
+ slug?: string
15
+ /** Present only on server/admin payloads — public DALs strip it. */
16
+ email?: string | null
17
+ }
@@ -16,6 +16,7 @@ export * from './auth'
16
16
 
17
17
  // Content types
18
18
  export * from './blog'
19
+ export * from './entity-author'
19
20
  export * from './case-study'
20
21
  export * from './customer-interview'
21
22
  export * from './customer-interview-ai.types'
@@ -95,6 +95,7 @@ export interface NavigationSidebarItem {
95
95
  icon: React.ReactNode
96
96
  path?: string
97
97
  badge?: string | React.ReactNode
98
+ unreadCount?: number
98
99
  isActive?: boolean
99
100
  onClick?: () => void
100
101
  children?: NavigationSidebarItem[]
@@ -2,6 +2,7 @@
2
2
  // Following the blog.ts pattern for consistency
3
3
 
4
4
  import type { PlatformRecord } from './platform'
5
+ import type { EntityAuthor } from './entity-author'
5
6
  import type { BlogTag, TagAssoc } from './blog'
6
7
  import type { VideoTeaser, TranscriptWord, SpeakerMapping } from './video-processing'
7
8
 
@@ -128,13 +129,7 @@ export interface ProductRelease {
128
129
  * `hydrateAuthor` in the hub DAL. Optional so admin form + every existing
129
130
  * consumer that doesn't read author stay backward-compatible.
130
131
  */
131
- author?: {
132
- id: string
133
- full_name: string
134
- avatar_url: string | null
135
- job_title: string | null
136
- email?: string | null
137
- }
132
+ author?: EntityAuthor
138
133
 
139
134
  // Timestamps
140
135
  created_at: string
@@ -49,6 +49,18 @@ export function truncateString(str: string, maxLength: number, suffix = "..."):
49
49
  return str.substring(0, maxLength - suffix.length) + suffix
50
50
  }
51
51
 
52
+ /**
53
+ * Serialize a JSON-LD schema for a `<script type="application/ld+json">`
54
+ * block — THE one home of the escape rule (the hub re-exports this from
55
+ * lib/utils/breadcrumbs). JSON.stringify does NOT HTML-escape: a stored
56
+ * "</script>" inside any admin/user-entered field would terminate the tag
57
+ * early (stored XSS). Every "<" becomes the JSON escape sequence
58
+ * backslash-u003c — still valid JSON, inert in HTML.
59
+ */
60
+ export function serializeJsonLd(schema: unknown): string {
61
+ return JSON.stringify(schema).replace(/</g, "\\u003c")
62
+ }
63
+
52
64
  /**
53
65
  * Deep clone an object
54
66
  * @param obj - Object to clone
@@ -12,7 +12,7 @@ export { formatDate, formatNumber, formatPrice, formatBytes } from './format'
12
12
  // SVG path constants — re-exported here (server-safe) because icons-v2 has "use client"
13
13
  export { PLAY_ICON_PATH } from '../components/icons-v2-generated/media-playback/play-icon'
14
14
  export { getPlatformAccentColor, getCurrentPlatform, type ColorCategory, HEX_PATTERN } from './ods-color-utils'
15
- export { delay, generateRandomString, truncateString, deepClone, getSlackCommunityJoinUrl } from './common'
15
+ export { delay, generateRandomString, truncateString, deepClone, getSlackCommunityJoinUrl, serializeJsonLd } from './common'
16
16
  export { getBaseUrl } from '../utils/cn'
17
17
 
18
18
  export * from './platform-config'