fragment-headless-sdk 2.4.4 → 2.5.0

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.
@@ -2,7 +2,7 @@
2
2
  import { XMarkIcon } from "@heroicons/react/24/outline";
3
3
  import React, { useEffect, useRef } from "react";
4
4
  import { AnnouncementType, ButtonType, SectionType } from "../../constants";
5
- import { fireImpressionWhenVisible, getThemeClasses, mergeSlotAttributes, mergeSlotClasses, mergeSlotStyles, resolveAnnouncementColors, } from "../../utils";
5
+ import { fireImpressionWhenVisible, getThemeClasses, mergeSlotAttributes, mergeSlotClasses, mergeSlotStyles, normalizeRichTextHtml, resolveAnnouncementColors, resolveAnnouncementTypography, } from "../../utils";
6
6
  import AnnouncementButton from "./AnnouncementButton";
7
7
  import { AnnouncementStyles } from "./AnnouncementStyles";
8
8
  import CountdownTimer from "./CountdownTimer";
@@ -18,14 +18,16 @@ export default function Announcement({ content, type, handleClose, }) {
18
18
  return null;
19
19
  const styling = content.styling;
20
20
  const colors = resolveAnnouncementColors(content);
21
+ const typography = resolveAnnouncementTypography(content);
22
+ const announcementHtml = normalizeRichTextHtml(content.announcementHtml);
21
23
  const themeClasses = getThemeClasses(styling);
22
- const rootClass = mergeSlotClasses(`relative w-full ${themeClasses}`, styling, "root");
24
+ const rootClass = mergeSlotClasses(`fragment-announcement relative w-full ${themeClasses}`, styling, "root");
23
25
  const rootStyle = mergeSlotStyles({
24
26
  backgroundColor: colors.background,
25
27
  color: colors.text,
26
28
  }, styling, "root");
27
29
  const rootAttributes = mergeSlotAttributes(styling, "root");
28
- const innerClass = mergeSlotClasses("relative mx-auto flex max-w-screen-xl flex-col md:flex-row items-center justify-center gap-4 px-10 md:px-4 py-2 md:py-0 text-center md:text-left min-h-[50px]", styling, "inner", "wrapper");
30
+ const innerClass = mergeSlotClasses("relative mx-auto flex max-w-screen-xl flex-col md:flex-row items-center justify-center gap-4 px-10 md:pl-4 md:pr-10 py-2 md:py-0 text-center min-h-[50px]", styling, "inner", "wrapper");
29
31
  const innerStyle = mergeSlotStyles(undefined, styling, "inner", "wrapper");
30
32
  const innerAttributes = mergeSlotAttributes(styling, "inner", "wrapper");
31
33
  const marqueeContainerClass = mergeSlotClasses("flex w-full flex-col md:flex-row items-center justify-between gap-2 md:gap-4 overflow-hidden md:pr-8", styling, "marqueeContainer");
@@ -37,14 +39,14 @@ export default function Announcement({ content, type, handleClose, }) {
37
39
  const marqueeTextClass = mergeSlotClasses("whitespace-nowrap animate-marquee", styling, "marqueeText");
38
40
  const marqueeTextStyle = mergeSlotStyles(undefined, styling, "marqueeText");
39
41
  const marqueeTextAttributes = mergeSlotAttributes(styling, "marqueeText");
40
- const marqueeContentClass = mergeSlotClasses("inline-block max-w-none text-base", styling, "marqueeContent");
41
- const marqueeContentStyle = mergeSlotStyles(undefined, styling, "marqueeContent");
42
+ const marqueeContentClass = mergeSlotClasses(`fragment-announcement-text inline-block max-w-none font-semibold ${typography.fontSize} ${typography.lineHeight}`, styling, "marqueeContent");
43
+ const marqueeContentStyle = mergeSlotStyles({ fontFamily: typography.fontFamily }, styling, "marqueeContent");
42
44
  const marqueeContentAttributes = mergeSlotAttributes(styling, "marqueeContent");
43
45
  const contentRowClass = mergeSlotClasses("flex flex-col md:flex-row items-center justify-center gap-2 md:gap-4 w-full", styling, "contentRow");
44
46
  const contentRowStyle = mergeSlotStyles(undefined, styling, "contentRow");
45
47
  const contentRowAttributes = mergeSlotAttributes(styling, "contentRow");
46
- const announcementTextClass = mergeSlotClasses("max-w-none text-based", styling, "announcementText");
47
- const announcementTextStyle = mergeSlotStyles(undefined, styling, "announcementText");
48
+ const announcementTextClass = mergeSlotClasses(`fragment-announcement-text max-w-none font-semibold ${typography.fontSize} ${typography.lineHeight}`, styling, "announcementText");
49
+ const announcementTextStyle = mergeSlotStyles({ fontFamily: typography.fontFamily }, styling, "announcementText");
48
50
  const announcementTextAttributes = mergeSlotAttributes(styling, "announcementText");
49
51
  const closeButtonClass = mergeSlotClasses("absolute right-4 top-1/2 -translate-y-1/2 cursor-pointer", styling, "closeButton");
50
52
  const closeButtonStyle = mergeSlotStyles({ color: colors.closeButton }, styling, "closeButton");
@@ -56,12 +58,12 @@ export default function Announcement({ content, type, handleClose, }) {
56
58
  React.createElement("div", { className: marqueeTextWrapperClass, style: marqueeTextWrapperStyle, ...(marqueeTextWrapperAttributes ?? {}) },
57
59
  React.createElement("div", { className: marqueeTextClass, style: marqueeTextStyle, ...(marqueeTextAttributes ?? {}) },
58
60
  React.createElement("div", { className: marqueeContentClass, style: marqueeContentStyle, ...(marqueeContentAttributes ?? {}), dangerouslySetInnerHTML: {
59
- __html: content.announcementHtml || "",
61
+ __html: announcementHtml,
60
62
  } }))),
61
63
  content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: buttonHref })))) : (React.createElement("div", { className: contentRowClass, style: contentRowStyle, ...(contentRowAttributes ?? {}) },
62
64
  React.createElement("div", { className: announcementTextClass, style: announcementTextStyle, ...(announcementTextAttributes ?? {}) },
63
65
  React.createElement("div", { dangerouslySetInnerHTML: {
64
- __html: content.announcementHtml || "",
66
+ __html: announcementHtml,
65
67
  } })),
66
68
  type === AnnouncementType.Countdown && (React.createElement(CountdownTimer, { content: content })),
67
69
  content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: buttonHref })))),
@@ -8,9 +8,9 @@ interface HeroViewProps {
8
8
  contentWidthClass: string;
9
9
  typography: ReturnType<typeof resolveHeroTypography>;
10
10
  position: "left" | "center" | "right";
11
- height: string;
11
+ heightCss: string;
12
12
  buttonSpacing?: string;
13
13
  contentSpacing?: string;
14
14
  }
15
- export default function DesktopHero({ buttonHref, content, colors, contentWidthClass, typography, position, height, buttonSpacing, contentSpacing, }: HeroViewProps): React.JSX.Element;
15
+ export default function DesktopHero({ buttonHref, content, colors, contentWidthClass, typography, position, heightCss, buttonSpacing, contentSpacing, }: HeroViewProps): React.JSX.Element;
16
16
  export {};
@@ -3,7 +3,7 @@ import { SectionType } from "../../constants";
3
3
  import { fireClickMetric } from "../../utils";
4
4
  import { DEFAULT_CONTENT_WIDTH_CLASS, joinClassNames, renderText, } from "../../utils/hero-resolvers";
5
5
  import HeroCountdownTimer from "./HeroCountdownTimer";
6
- export default function DesktopHero({ buttonHref, content, colors, contentWidthClass, typography, position, height, buttonSpacing, contentSpacing, }) {
6
+ export default function DesktopHero({ buttonHref, content, colors, contentWidthClass, typography, position, heightCss, buttonSpacing, contentSpacing, }) {
7
7
  const getPositionClasses = () => {
8
8
  switch (position) {
9
9
  case "center":
@@ -18,7 +18,7 @@ export default function DesktopHero({ buttonHref, content, colors, contentWidthC
18
18
  const handleClick = React.useCallback(() => {
19
19
  fireClickMetric(content.measurementId, content.sectionType || SectionType.HeroBanner, content.sectionId);
20
20
  }, [content.measurementId, content.sectionType, content.sectionId]);
21
- return (React.createElement("div", { className: "relative gap-4 w-full", style: { backgroundColor: colors.background, height } },
21
+ return (React.createElement("div", { className: "relative gap-4 w-full", style: { backgroundColor: colors.background, height: heightCss } },
22
22
  content?.videoUrl ? (React.createElement("video", { src: content.videoUrl, autoPlay: true, muted: true, loop: true, playsInline: true, className: "absolute inset-0 z-0 object-cover w-full h-full" })) : (
23
23
  /* Image Background */
24
24
  content?.imageUrl && (React.createElement("img", { src: content.imageUrl, alt: content.title || "Hero", className: "absolute inset-0 z-0 object-cover w-full h-full" }))),
@@ -19,12 +19,12 @@ export default function Hero({ content }) {
19
19
  const contentWidthClass = resolveContentWidthClass(content);
20
20
  const typography = resolveHeroTypography(content);
21
21
  const position = resolvePosition(content);
22
- const height = resolveHeight(content);
22
+ const heightCss = resolveHeight(content);
23
23
  const buttonSpacing = resolveButtonSpacing(content);
24
24
  const contentSpacing = resolveContentSpacing(content);
25
25
  return (React.createElement("div", { className: "bg-black", ref: ref, style: { backgroundColor: colors.background } },
26
26
  React.createElement("div", { className: "hidden lg:block" },
27
- React.createElement(DesktopHero, { content: content, buttonHref: buttonHref, colors: colors, contentWidthClass: contentWidthClass, typography: typography, position: position, height: height, buttonSpacing: buttonSpacing, contentSpacing: contentSpacing })),
27
+ React.createElement(DesktopHero, { content: content, buttonHref: buttonHref, colors: colors, contentWidthClass: contentWidthClass, typography: typography, position: position, heightCss: heightCss, buttonSpacing: buttonSpacing, contentSpacing: contentSpacing })),
28
28
  React.createElement("div", { className: "block lg:hidden" },
29
29
  React.createElement(MobileHero, { content: content, buttonHref: buttonHref, colors: colors, typography: typography, buttonSpacing: buttonSpacing, contentSpacing: contentSpacing }))));
30
30
  }
@@ -29,3 +29,14 @@
29
29
  color: currentColor;
30
30
  text-decoration: underline;
31
31
  }
32
+
33
+ /* Announcement text links: always inherit the surrounding text color and stay underlined */
34
+ .fragment-announcement-text a {
35
+ color: currentColor;
36
+ text-decoration: underline;
37
+ }
38
+
39
+ .fragment-announcement-text a:hover {
40
+ color: currentColor;
41
+ text-decoration: underline;
42
+ }
@@ -1,4 +1,5 @@
1
1
  import { IAnnouncementContent } from "../types";
2
+ import { FontKey } from "./hero-resolvers";
2
3
  export interface AnnouncementResolvedColors {
3
4
  background: string;
4
5
  text: string;
@@ -11,5 +12,12 @@ export interface CountdownResolvedColors {
11
12
  digitBackgroundColor: string;
12
13
  textColor: string;
13
14
  }
15
+ export interface AnnouncementTypographySettings {
16
+ fontSize: string;
17
+ lineHeight: string;
18
+ font: FontKey;
19
+ fontFamily: string;
20
+ }
14
21
  export declare function resolveAnnouncementColors(content: IAnnouncementContent): AnnouncementResolvedColors;
22
+ export declare function resolveAnnouncementTypography(content: IAnnouncementContent): AnnouncementTypographySettings;
15
23
  export declare function resolveCountdownColors(content: IAnnouncementContent): CountdownResolvedColors;
@@ -1,3 +1,4 @@
1
+ import { FONT_FAMILY_MAP } from "./hero-resolvers";
1
2
  import { resolveToken, resolveTokenByCategory } from "./styling";
2
3
  const DEFAULT_ANNOUNCEMENT_COLORS = {
3
4
  background: "#000000",
@@ -6,6 +7,12 @@ const DEFAULT_ANNOUNCEMENT_COLORS = {
6
7
  buttonText: "#000000",
7
8
  closeButton: "#ffffff",
8
9
  };
10
+ const DEFAULT_ANNOUNCEMENT_TYPOGRAPHY = {
11
+ fontSize: "text-base",
12
+ lineHeight: "leading-relaxed",
13
+ font: "geist",
14
+ fontFamily: FONT_FAMILY_MAP.geist,
15
+ };
9
16
  const DEFAULT_COUNTDOWN_COLORS = {
10
17
  digitColor: "#ffffff",
11
18
  digitBackgroundColor: "#000000",
@@ -32,6 +39,23 @@ export function resolveAnnouncementColors(content) {
32
39
  closeButton: fallbackColor(closeButton, fallbackColor(text, DEFAULT_ANNOUNCEMENT_COLORS.closeButton)),
33
40
  };
34
41
  }
42
+ export function resolveAnnouncementTypography(content) {
43
+ const typography = content?.styling?.tokens?.typography;
44
+ const rawFont = typography?.announcementFont;
45
+ const isValidFont = typeof rawFont === "string" &&
46
+ Object.prototype.hasOwnProperty.call(FONT_FAMILY_MAP, rawFont);
47
+ const font = isValidFont
48
+ ? rawFont
49
+ : DEFAULT_ANNOUNCEMENT_TYPOGRAPHY.font;
50
+ return {
51
+ fontSize: typography?.announcementFontSize ??
52
+ DEFAULT_ANNOUNCEMENT_TYPOGRAPHY.fontSize,
53
+ lineHeight: typography?.announcementLineHeight ??
54
+ DEFAULT_ANNOUNCEMENT_TYPOGRAPHY.lineHeight,
55
+ font,
56
+ fontFamily: FONT_FAMILY_MAP[font],
57
+ };
58
+ }
35
59
  export function resolveCountdownColors(content) {
36
60
  const styling = content.styling;
37
61
  const digitColor = resolveToken(styling, "counterDigitColor");
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { IHeroContent } from "../types";
3
- import { CountdownResolvedColors } from "./announcement-resolvers";
3
+ import type { CountdownResolvedColors } from "./announcement-resolvers";
4
4
  export interface HeroResolvedColors {
5
5
  title: string;
6
6
  text: string;
@@ -11,6 +11,15 @@ export interface HeroResolvedColors {
11
11
  export declare const DEFAULT_COLORS: HeroResolvedColors;
12
12
  export declare const DEFAULT_CONTENT_WIDTH_CLASS = "w-2/5";
13
13
  export declare const DEFAULT_HEIGHT_CSS = "400px";
14
+ /**
15
+ * Converts a Tailwind height class to a CSS value for use in inline styles.
16
+ * Handles arbitrary values (`h-[400px]`), named tokens (`h-screen`), and
17
+ * raw CSS values passed through unchanged (`400px`, `50vh`).
18
+ *
19
+ * Inline style is required because height tokens are stored in the database
20
+ * at runtime — Tailwind's static scanner cannot include them in the CSS bundle.
21
+ */
22
+ export declare function parseTailwindHeight(cls: string): string;
14
23
  export declare const FONT_FAMILY_MAP: {
15
24
  readonly geist: "\"Geist\", -apple-system, BlinkMacSystemFont, sans-serif";
16
25
  readonly georgia: "\"Georgia\", \"Times New Roman\", serif";
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { normalizeRichTextHtml } from "./html";
2
3
  import { resolveToken } from "./styling";
3
4
  export const DEFAULT_COLORS = {
4
5
  title: "#ffffff",
@@ -10,13 +11,18 @@ export const DEFAULT_COLORS = {
10
11
  export const DEFAULT_CONTENT_WIDTH_CLASS = "w-2/5";
11
12
  export const DEFAULT_HEIGHT_CSS = "400px";
12
13
  /**
13
- * Converts a Tailwind height class (e.g. "h-[400px]", "h-screen") to a CSS
14
- * value string so it can be applied via inline style. This avoids Tailwind's
15
- * static-scan limitation where dynamic class names from the database are never
16
- * included in the generated CSS bundle.
14
+ * Converts a Tailwind height class to a CSS value for use in inline styles.
15
+ * Handles arbitrary values (`h-[400px]`), named tokens (`h-screen`), and
16
+ * raw CSS values passed through unchanged (`400px`, `50vh`).
17
+ *
18
+ * Inline style is required because height tokens are stored in the database
19
+ * at runtime — Tailwind's static scanner cannot include them in the CSS bundle.
17
20
  */
18
- function parseTailwindHeight(cls) {
19
- const match = cls.match(/^h-\[(.+)\]$/);
21
+ export function parseTailwindHeight(cls) {
22
+ const trimmed = cls?.trim() ?? "";
23
+ if (trimmed.length === 0)
24
+ return DEFAULT_HEIGHT_CSS;
25
+ const match = trimmed.match(/^h-\[(.+)\]$/);
20
26
  if (match)
21
27
  return match[1];
22
28
  const map = {
@@ -25,7 +31,12 @@ function parseTailwindHeight(cls) {
25
31
  "h-auto": "auto",
26
32
  "h-fit": "fit-content",
27
33
  };
28
- return map[cls] ?? DEFAULT_HEIGHT_CSS;
34
+ if (map[trimmed])
35
+ return map[trimmed];
36
+ // Pass through values that are already valid CSS (e.g. "500px", "50vh")
37
+ if (!trimmed.startsWith("h-"))
38
+ return trimmed;
39
+ return DEFAULT_HEIGHT_CSS;
29
40
  }
30
41
  export const FONT_FAMILY_MAP = {
31
42
  geist: '"Geist", -apple-system, BlinkMacSystemFont, sans-serif',
@@ -149,8 +160,7 @@ export function renderText({ fontSize, lineHeight, text, className, color, font,
149
160
  const combinedClasses = className
150
161
  ? `${baseClasses} ${fontSize} ${lineHeight} ${className}`
151
162
  : `${baseClasses} ${fontSize} ${lineHeight}`;
152
- // &nbsp; from rich text editors prevents word wrapping; replace with regular spaces.
153
- const displayHtml = text.replace(/&nbsp;/g, "\u0020");
163
+ const displayHtml = normalizeRichTextHtml(text);
154
164
  return React.createElement("div", {
155
165
  className: combinedClasses,
156
166
  style: {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Rich-text editors often emit `&nbsp;` between words to preserve spacing.
3
+ * That prevents normal word wrapping and causes overflow in constrained
4
+ * containers (marquees, single-line announcements, heros). Replace them with
5
+ * regular spaces so the browser can break on them as expected.
6
+ */
7
+ export declare const normalizeRichTextHtml: (html: string | null | undefined) => string;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Rich-text editors often emit `&nbsp;` between words to preserve spacing.
3
+ * That prevents normal word wrapping and causes overflow in constrained
4
+ * containers (marquees, single-line announcements, heros). Replace them with
5
+ * regular spaces so the browser can break on them as expected.
6
+ */
7
+ export const normalizeRichTextHtml = (html) => (html ?? "").replace(/&nbsp;/g, "\u0020");
@@ -3,5 +3,6 @@ export * from "./attribution";
3
3
  export * from "./cache";
4
4
  export * from "./fetch-resource";
5
5
  export * from "./hero-resolvers";
6
+ export * from "./html";
6
7
  export * from "./metrics";
7
8
  export * from "./styling";
@@ -3,5 +3,6 @@ export * from "./attribution";
3
3
  export * from "./cache";
4
4
  export * from "./fetch-resource";
5
5
  export * from "./hero-resolvers";
6
+ export * from "./html";
6
7
  export * from "./metrics";
7
8
  export * from "./styling";
package/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ### [Unreleased]
9
9
 
10
+ #### Added
11
+
12
+ - **`normalizeRichTextHtml` utility** – New shared helper in `utils/html.ts` that normalizes rich-text editor output (replaces `&nbsp;` with regular spaces so words wrap at natural boundaries). Exported from the SDK entrypoint and used by both `Announcement` and `renderText` in `hero-resolvers`, replacing previously duplicated inline implementations.
13
+ - **`parseTailwindHeight` is now public** – Previously an internal helper, now exported so preview/template consumers (e.g. `HeroBannerTemplate`) can share the same Tailwind-height → CSS conversion. Also accepts raw CSS values (`500px`, `50vh`) as pass-through (`hero-resolvers.ts`).
14
+ - **`AnnouncementTypographySettings.fontFamily`** – `resolveAnnouncementTypography` now returns a resolved `fontFamily` CSS value alongside `font`/`fontSize`/`lineHeight`, so callers no longer need to import `FONT_FAMILY_MAP` to translate the font key themselves.
15
+ - **Font key validation in `resolveAnnouncementTypography`** – Invalid/stale `announcementFont` values stored in the database now fall back to `geist` instead of being silently cast to a non-existent `FontKey` (`announcement-resolvers.ts`).
16
+
17
+ #### Changed
18
+
19
+ - **Announcement applies typography tokens** – `Announcement` now applies `announcementFontSize`, `announcementLineHeight`, and `announcementFont` (as `fontFamily`) to marquee and standard text elements. Previously these tokens were ignored and text always rendered at `text-base` with no font override (`components/Announcement/index.tsx`).
20
+ - **Announcement inner wrapper padding** – Reserves right-side padding on `md+` (`md:pl-4 md:pr-10`) so the close button no longer overlaps long announcement text, and drops `md:text-left` so copy stays centered across breakpoints (`components/Announcement/index.tsx`).
21
+ - **`DesktopHero` prop rename** – `height` → `heightCss` to reflect that the value is a CSS string, not a Tailwind class. Callers updated accordingly (`components/Hero/DesktopHero.tsx`, `components/Hero/index.tsx`).
22
+
23
+ #### Fixed
24
+
25
+ - **Announcement text breaking mid-word** – `Announcement` now normalizes `&nbsp;` entities to regular spaces before rendering so words wrap at natural boundaries. Applied to both marquee and standard/countdown layouts (`components/Announcement/index.tsx`).
26
+ - **Announcement text class typo** – Replaced `max-w-none text-based` (invalid Tailwind class) with the correct resolved `${fontSize} ${lineHeight}` tokens (`components/Announcement/index.tsx`).
27
+ - **Announcement text links** – Links inside `.fragment-announcement-text` now inherit surrounding text color and stay underlined on hover, matching the existing hero link behavior (`styles/fragment-sdk.css`).
28
+
10
29
  ### [2.4.4] - 2026-04-14
11
30
 
12
31
  #### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fragment-headless-sdk",
3
- "version": "2.4.4",
3
+ "version": "2.5.0",
4
4
  "description": "Official SDK for Fragment-Shopify CMS: React components, TypeScript types, and utilities for headless Shopify storefronts.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",