@windstream/react-shared-components 0.2.82 → 0.2.84

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.82",
3
+ "version": "0.2.84",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -13,7 +13,7 @@ const meta: Meta<typeof AwardMarquee> = {
13
13
  page: DocsPage,
14
14
  description: {
15
15
  component:
16
- "Server-rendered marquee of award badges. Optional `heading` and `footerText` display gold text above and below the awards, with divider lines around the heading. Animates when `scroll` is set; with 3 or fewer items it animates on mobile and falls back to a static row at the `sm` breakpoint (CSS-driven, no client-side JS).",
16
+ "Server-rendered marquee of award badges. Optional `heading` and `footerText` display gold text above and below the awards, with divider lines around the heading. A single-entry static display can optionally swap in a distinct `mobileIconUrl` below the `sm` breakpoint. Animates when `scroll` is set; with 3 or fewer items it animates on mobile and falls back to a static row at the `sm` breakpoint (CSS-driven, no client-side JS).",
17
17
  },
18
18
  },
19
19
  },
@@ -56,3 +56,47 @@ export const WithHeadingAndFooter: Story = {
56
56
  scroll: false,
57
57
  },
58
58
  };
59
+
60
+ export const SingleConsolidatedLogo: Story = {
61
+ args: {
62
+ heading: "As seen on",
63
+ scroll: false,
64
+ items: [
65
+ {
66
+ iconUrl: "https://placehold.co/1441x622?text=Consolidated+Logo",
67
+ iconAlt: "As seen on logo collection",
68
+ },
69
+ ],
70
+ },
71
+ parameters: {
72
+ docs: {
73
+ description: {
74
+ story:
75
+ "A single consolidated logo entry (no title/description) renders at up to 1100px wide, scales down responsively, and is horizontally centered. The caption block is omitted entirely since there is no caption text.",
76
+ },
77
+ },
78
+ },
79
+ };
80
+
81
+ export const SingleLogoWithMobileImage: Story = {
82
+ args: {
83
+ heading: "As seen on",
84
+ scroll: false,
85
+ items: [
86
+ {
87
+ iconUrl: "https://placehold.co/1441x622?text=Desktop+Logo",
88
+ iconAlt: "As seen on logo collection (desktop)",
89
+ mobileIconUrl: "https://placehold.co/622x622?text=Mobile+Logo",
90
+ mobileIconAlt: "As seen on logo collection (mobile)",
91
+ },
92
+ ],
93
+ },
94
+ parameters: {
95
+ docs: {
96
+ description: {
97
+ story:
98
+ "A single consolidated logo entry can optionally supply `mobileIconUrl` (plus matching `mobileIconAlt`/`mobileIconWidth`/`mobileIconHeight`) to swap in a different, often more vertical, crop below the `sm` (640px) breakpoint. Resize the viewport to see the swap. If `mobileIconUrl` is omitted, the desktop image is used at every breakpoint. This swap only applies when the marquee has a single entry and `scroll` is `false`.",
99
+ },
100
+ },
101
+ },
102
+ };
@@ -92,17 +92,128 @@ describe("AwardMarquee", () => {
92
92
  expect(icon.src).toContain("icon-0.png");
93
93
  });
94
94
 
95
- it("renders intrinsic icon dimensions when provided", () => {
96
- const [item] = buildItems(1);
97
- render(
98
- <AwardMarquee items={[{ ...item, iconWidth: 320, iconHeight: 180 }]} />
99
- );
95
+ it("renders intrinsic icon dimensions when provided (multi-entry, unchanged sizing)", () => {
96
+ const items = buildItems(2).map(item => ({
97
+ ...item,
98
+ iconWidth: 320,
99
+ iconHeight: 180,
100
+ }));
101
+ render(<AwardMarquee items={items} />);
100
102
  const icon = screen.getByAltText("Award 0");
101
103
  expect(icon).toHaveAttribute("width", "320");
102
104
  expect(icon).toHaveAttribute("height", "180");
103
105
  expect(icon).toHaveClass("w-[200px]");
104
106
  });
105
107
 
108
+ it("renders intrinsic icon dimensions when provided (single static entry)", () => {
109
+ const [item] = buildItems(1);
110
+ render(
111
+ <AwardMarquee items={[{ ...item, iconWidth: 1441, iconHeight: 622 }]} />
112
+ );
113
+ const icon = screen.getByAltText("Award 0");
114
+ expect(icon).toHaveAttribute("width", "1441");
115
+ expect(icon).toHaveAttribute("height", "622");
116
+ expect(icon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
117
+ expect(icon).not.toHaveClass("w-[200px]");
118
+ });
119
+
120
+ it("renders a distinct mobile image for a single static entry when one is supplied", () => {
121
+ const { container } = render(
122
+ <AwardMarquee
123
+ items={[
124
+ {
125
+ iconUrl: "https://example.com/desktop-logo.png",
126
+ iconAlt: "Desktop Logo",
127
+ iconWidth: 1441,
128
+ iconHeight: 622,
129
+ mobileIconUrl: "https://example.com/mobile-logo.png",
130
+ mobileIconAlt: "Mobile Logo",
131
+ mobileIconWidth: 600,
132
+ mobileIconHeight: 400,
133
+ },
134
+ ]}
135
+ scroll={false}
136
+ />
137
+ );
138
+
139
+ const mobileIcon = screen.getByAltText("Mobile Logo") as HTMLImageElement;
140
+ const desktopIcon = screen.getByAltText("Desktop Logo") as HTMLImageElement;
141
+
142
+ expect(mobileIcon.src).toContain("mobile-logo.png");
143
+ expect(mobileIcon).toHaveAttribute("width", "600");
144
+ expect(mobileIcon).toHaveAttribute("height", "400");
145
+ expect(mobileIcon).toHaveClass("sm:hidden", "max-w-[1100px]", "h-auto");
146
+
147
+ expect(desktopIcon.src).toContain("desktop-logo.png");
148
+ expect(desktopIcon).toHaveAttribute("width", "1441");
149
+ expect(desktopIcon).toHaveAttribute("height", "622");
150
+ expect(desktopIcon).toHaveClass(
151
+ "hidden",
152
+ "sm:block",
153
+ "max-w-[1100px]",
154
+ "h-auto"
155
+ );
156
+
157
+ // Exactly one wrapper div (not duplicated) still holds both images.
158
+ expect(
159
+ container.querySelectorAll("#Award-Marquee > div > div")
160
+ ).toHaveLength(1);
161
+ });
162
+
163
+ it("falls back to the desktop image at every breakpoint when no mobile image is supplied", () => {
164
+ render(
165
+ <AwardMarquee
166
+ items={[
167
+ {
168
+ iconUrl: "https://example.com/desktop-logo.png",
169
+ iconAlt: "Desktop Logo",
170
+ },
171
+ ]}
172
+ scroll={false}
173
+ />
174
+ );
175
+
176
+ const icon = screen.getByAltText("Desktop Logo");
177
+ expect(icon).not.toHaveClass("sm:hidden");
178
+ expect(icon).not.toHaveClass("hidden", "sm:block");
179
+ expect(screen.queryAllByAltText("Desktop Logo")).toHaveLength(1);
180
+ });
181
+
182
+ it("ignores the mobile image field for multi-entry and animated-carousel rendering", () => {
183
+ const itemWithMobile = {
184
+ iconUrl: "https://example.com/desktop-logo.png",
185
+ iconAlt: "Desktop Logo",
186
+ mobileIconUrl: "https://example.com/mobile-logo.png",
187
+ mobileIconAlt: "Mobile Logo",
188
+ };
189
+
190
+ const { container: multiContainer } = render(
191
+ <AwardMarquee
192
+ items={[itemWithMobile, { ...buildItems(1)[0] }]}
193
+ scroll={false}
194
+ />
195
+ );
196
+ expect(multiContainer.querySelectorAll('[alt="Mobile Logo"]')).toHaveLength(
197
+ 0
198
+ );
199
+ expect(screen.getByAltText("Desktop Logo")).not.toHaveClass("sm:hidden");
200
+
201
+ const { container: carouselContainer } = render(
202
+ <AwardMarquee items={[itemWithMobile]} scroll={true} />
203
+ );
204
+ // Split view for a single item never applies the mobile/desktop swap:
205
+ // the mobile half is already handled by the separate animated carousel,
206
+ // and the desktop half shows a single, unswapped image.
207
+ expect(
208
+ carouselContainer.querySelectorAll('[alt="Mobile Logo"]')
209
+ ).toHaveLength(0);
210
+ const desktopHalfIcon = carouselContainer.querySelector(
211
+ ".hidden.sm\\:block img"
212
+ );
213
+ expect(desktopHalfIcon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
214
+ expect(desktopHalfIcon).not.toHaveClass("sm:hidden");
215
+ });
216
+
106
217
  it("omits intrinsic icon dimensions when unavailable", () => {
107
218
  render(<AwardMarquee items={buildItems(1)} />);
108
219
  const icon = screen.getByAltText("Award 0");
@@ -139,6 +250,93 @@ describe("AwardMarquee", () => {
139
250
  expect(screen.getAllByText("Award 0")).toHaveLength(3);
140
251
  });
141
252
 
253
+ it("centers a single static entry and hides the caption block when it has no caption text", () => {
254
+ const { container } = render(
255
+ <AwardMarquee
256
+ items={[{ iconUrl: "https://example.com/logo.png", iconAlt: "Logo" }]}
257
+ scroll={false}
258
+ />
259
+ );
260
+ const wrapper = container.querySelector("#Award-Marquee > div > div");
261
+ expect(wrapper).toHaveClass("mx-auto", "w-full", "justify-center");
262
+ expect(wrapper).not.toHaveClass("ml-4", "w-auto");
263
+
264
+ const icon = screen.getByAltText("Logo");
265
+ expect(icon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
266
+ expect(icon).not.toHaveClass("w-[200px]");
267
+
268
+ // No title/description -> the 250px caption block takes no space.
269
+ expect(container.querySelector(".w-\\[250px\\]")).toBeNull();
270
+ });
271
+
272
+ it("still renders the caption block for a single static entry that has caption text", () => {
273
+ const { container } = render(
274
+ <AwardMarquee items={buildItems(1)} scroll={false} />
275
+ );
276
+ expect(container.querySelector(".w-\\[250px\\]")).not.toBeNull();
277
+ expect(screen.getByText("Award 0")).toBeInTheDocument();
278
+ expect(screen.getByText("Description 0")).toBeInTheDocument();
279
+ });
280
+
281
+ it("omits the caption block for any entry lacking caption text, even with multiple entries", () => {
282
+ const items: AwardMarqueeItem[] = [
283
+ ...buildItems(1),
284
+ { iconUrl: "https://example.com/logo-2.png", iconAlt: "Logo 2" },
285
+ ];
286
+ const { container } = render(<AwardMarquee items={items} scroll={false} />);
287
+ // Only the first item (with title/description) gets a caption block.
288
+ expect(container.querySelectorAll(".w-\\[250px\\]")).toHaveLength(1);
289
+ });
290
+
291
+ it("leaves multi-entry static rendering unchanged (fixed-width icon, ml-4 wrapper)", () => {
292
+ const { container } = render(
293
+ <AwardMarquee items={buildItems(2)} scroll={false} />
294
+ );
295
+ const wrappers = container.querySelectorAll("#Award-Marquee > div > div");
296
+ wrappers.forEach(wrapper => {
297
+ expect(wrapper).toHaveClass("ml-4", "w-auto");
298
+ expect(wrapper).not.toHaveClass("mx-auto", "justify-center");
299
+ });
300
+ const icon = screen.getByAltText("Award 0");
301
+ expect(icon).toHaveClass("w-[200px]");
302
+ expect(icon).not.toHaveClass("max-w-[1100px]");
303
+ });
304
+
305
+ it("leaves the multi-entry animated carousel unchanged regardless of single-item styling", () => {
306
+ const { container } = render(
307
+ <AwardMarquee items={buildItems(5)} scroll={true} />
308
+ );
309
+ const icons = screen.getAllByAltText(/Award \d/);
310
+ icons.forEach(icon => expect(icon).toHaveClass("w-[200px]"));
311
+ const wrappers = container.querySelectorAll(".animate-award-marquee > div");
312
+ wrappers.forEach(wrapper => {
313
+ expect(wrapper).toHaveClass("ml-4", "w-auto");
314
+ });
315
+ });
316
+
317
+ it("applies the single-static treatment only to the desktop (static) half of a split view", () => {
318
+ const { container } = render(
319
+ <AwardMarquee
320
+ items={[{ iconUrl: "https://example.com/logo.png", iconAlt: "Logo" }]}
321
+ scroll={true}
322
+ />
323
+ );
324
+ const mobileIcon = container.querySelector(
325
+ ".sm\\:hidden img"
326
+ ) as HTMLImageElement;
327
+ const desktopIcon = container.querySelector(
328
+ ".hidden.sm\\:block img"
329
+ ) as HTMLImageElement;
330
+
331
+ // Mobile (animated) half keeps the original fixed-width treatment.
332
+ expect(mobileIcon).toHaveClass("w-[200px]");
333
+ expect(mobileIcon).not.toHaveClass("max-w-[1100px]");
334
+
335
+ // Desktop (static) half gets the large, centered single-entry treatment.
336
+ expect(desktopIcon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
337
+ expect(desktopIcon).not.toHaveClass("w-[200px]");
338
+ });
339
+
142
340
  it("applies the anchorId to the wrapper in every render branch", () => {
143
341
  const staticView = render(
144
342
  <AwardMarquee
@@ -26,32 +26,76 @@ const renderAwardItem = (
26
26
  iconAlt,
27
27
  iconWidth,
28
28
  iconHeight,
29
+ mobileIconUrl,
30
+ mobileIconAlt,
31
+ mobileIconWidth,
32
+ mobileIconHeight,
29
33
  title,
30
34
  description,
31
35
  }: AwardMarqueeItem,
32
- key: React.Key
33
- ) => (
34
- <div
35
- key={key}
36
- className="ml-4 flex w-auto flex-shrink-0 items-center whitespace-nowrap"
37
- >
38
- <img
39
- src={iconUrl}
40
- alt={iconAlt ?? ""}
41
- width={iconWidth}
42
- height={iconHeight}
43
- className="w-[200px]"
44
- />
45
- <div className="w-[250px] text-center">
46
- <p className="flex justify-center text-center text-body3 font-bold">
47
- {title}
48
- </p>
49
- <p className="flex flex-wrap whitespace-normal text-center text-footnote">
50
- {description}
51
- </p>
36
+ key: React.Key,
37
+ // Only the single-entry, non-animated (static) rendering gets the large,
38
+ // centered treatment; the animated carousel is unaffected.
39
+ isSingleStatic = false,
40
+ // A distinct mobile image is only honored for the standalone static render
41
+ // (scroll={false}). When this is the "desktop half" of a responsive split
42
+ // (scroll={true} with <=3 items), the sm:hidden mobile side is already
43
+ // handled by the separate animated half, so a nested swap here would never
44
+ // be visible at any viewport.
45
+ allowMobileSwap = false
46
+ ) => {
47
+ const hasCaption = Boolean(title || description);
48
+ const hasMobileIcon = allowMobileSwap && Boolean(mobileIconUrl);
49
+
50
+ return (
51
+ <div
52
+ key={key}
53
+ className={cx(
54
+ "flex flex-shrink-0 items-center whitespace-nowrap",
55
+ isSingleStatic ? "mx-auto w-full justify-center" : "ml-4 w-auto"
56
+ )}
57
+ >
58
+ {hasMobileIcon ? (
59
+ <>
60
+ <img
61
+ src={mobileIconUrl}
62
+ alt={mobileIconAlt ?? iconAlt ?? ""}
63
+ width={mobileIconWidth}
64
+ height={mobileIconHeight}
65
+ className="h-auto w-full max-w-[1100px] sm:hidden"
66
+ />
67
+ <img
68
+ src={iconUrl}
69
+ alt={iconAlt ?? ""}
70
+ width={iconWidth}
71
+ height={iconHeight}
72
+ className="hidden h-auto w-full max-w-[1100px] sm:block"
73
+ />
74
+ </>
75
+ ) : (
76
+ <img
77
+ src={iconUrl}
78
+ alt={iconAlt ?? ""}
79
+ width={iconWidth}
80
+ height={iconHeight}
81
+ className={
82
+ isSingleStatic ? "h-auto w-full max-w-[1100px]" : "w-[200px]"
83
+ }
84
+ />
85
+ )}
86
+ {hasCaption && (
87
+ <div className="w-[250px] text-center">
88
+ <p className="flex justify-center text-center text-body3 font-bold">
89
+ {title}
90
+ </p>
91
+ <p className="flex flex-wrap whitespace-normal text-center text-footnote">
92
+ {description}
93
+ </p>
94
+ </div>
95
+ )}
52
96
  </div>
53
- </div>
54
- );
97
+ );
98
+ };
55
99
 
56
100
  export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
57
101
  anchorId = "Award-Marquee",
@@ -91,20 +135,27 @@ export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
91
135
  );
92
136
  };
93
137
 
94
- const staticMarquee = (
138
+ const staticMarquee = (allowMobileSwap: boolean) => (
95
139
  <div className="flex flex-nowrap items-center justify-around overflow-clip">
96
- {items.map((item, index) => renderAwardItem(item, index))}
140
+ {items.map((item, index) =>
141
+ renderAwardItem(
142
+ item,
143
+ index,
144
+ items.length === 1,
145
+ allowMobileSwap && items.length === 1
146
+ )
147
+ )}
97
148
  </div>
98
149
  );
99
150
 
100
151
  const marquee = !scroll ? (
101
- staticMarquee
152
+ staticMarquee(true)
102
153
  ) : scrollAvailable ? (
103
154
  animatedMarquee()
104
155
  ) : (
105
156
  <>
106
157
  <div className="sm:hidden">{animatedMarquee()}</div>
107
- <div className="hidden sm:block">{staticMarquee}</div>
158
+ <div className="hidden sm:block">{staticMarquee(false)}</div>
108
159
  </>
109
160
  );
110
161
 
@@ -7,6 +7,12 @@ export type AwardMarqueeItem = {
7
7
  iconAlt?: string;
8
8
  iconWidth?: number;
9
9
  iconHeight?: number;
10
+ // Optional mobile-specific image for the single-entry static display.
11
+ // Falls back to iconUrl at every breakpoint when omitted.
12
+ mobileIconUrl?: string;
13
+ mobileIconAlt?: string;
14
+ mobileIconWidth?: number;
15
+ mobileIconHeight?: number;
10
16
  title?: string;
11
17
  description?: string;
12
18
  };
@@ -17,6 +17,7 @@ import {
17
17
  removeUTMs,
18
18
  renderContentfulRichText,
19
19
  renderContentfulRichTextTable,
20
+ replaceTokens,
20
21
  setCookie,
21
22
  setUTMs,
22
23
  SpeedCardBg,
@@ -92,6 +93,10 @@ describe("utils barrel exports", () => {
92
93
  expect(typeof toDocument).toBe("function");
93
94
  });
94
95
 
96
+ it("exports replaceTokens", () => {
97
+ expect(typeof replaceTokens).toBe("function");
98
+ });
99
+
95
100
  it("exports renderContentfulRichText", () => {
96
101
  expect(typeof renderContentfulRichText).toBe("function");
97
102
  });
@@ -32,6 +32,16 @@ export {
32
32
  redirectToWifiGrantUrl,
33
33
  } from "./wifi-grant-redirect";
34
34
 
35
+ // Token replacement — framework-agnostic string / rich-text interpolation.
36
+ // No dependency on Contentful or any particular CMS/tool.
37
+ export { replaceTokens } from "./replace-tokens";
38
+ export type {
39
+ TokenEntry,
40
+ TokenSource,
41
+ RichTextDocument,
42
+ ReplaceTokensResult,
43
+ } from "./replace-tokens";
44
+
35
45
  // Contentful utilities (server-safe, no "use client")
36
46
  export { toDocument } from "./contentful/to-document";
37
47
  export {
@@ -0,0 +1,197 @@
1
+ import {
2
+ replaceTokens,
3
+ type RichTextDocument,
4
+ type TokenEntry,
5
+ type TokenSource,
6
+ } from "./replace-tokens";
7
+
8
+ // Generic rich-text node types. This utility is framework-agnostic and
9
+ // intentionally has no dependency on any CMS or tool.
10
+ const DOCUMENT = "document";
11
+ const PARAGRAPH = "paragraph";
12
+ const HR = "hr";
13
+
14
+ const tokens: TokenEntry[] = [
15
+ { token: "price_tier1_300mbps", value: "44.99", promo: "39.99" },
16
+ { token: "price_value_only", value: "50.00" },
17
+ { token: "reward_50", value: "50" },
18
+ ];
19
+
20
+ const makeDocument = (value: string): RichTextDocument => ({
21
+ nodeType: DOCUMENT,
22
+ content: [
23
+ {
24
+ nodeType: PARAGRAPH,
25
+ content: [{ nodeType: "text", value, marks: [], data: {} }],
26
+ },
27
+ ],
28
+ });
29
+
30
+ describe("replaceTokens - strings", () => {
31
+ it("resolves a price placeholder to its promo value", () => {
32
+ expect(replaceTokens("Only ${price_tier1_300mbps}/mo", tokens)).toBe(
33
+ "Only 39.99/mo"
34
+ );
35
+ });
36
+
37
+ it("falls back to value when the entry has no promo (reward tokens)", () => {
38
+ expect(replaceTokens("Get a $${reward_50} reward", tokens)).toBe(
39
+ "Get a $50 reward"
40
+ );
41
+ expect(replaceTokens("${price_value_only}", tokens)).toBe("50.00");
42
+ });
43
+
44
+ it("replaces multiple placeholders in one string", () => {
45
+ expect(
46
+ replaceTokens("${price_tier1_300mbps} then ${reward_50}", tokens)
47
+ ).toBe("39.99 then 50");
48
+ });
49
+
50
+ it("leaves unknown placeholders intact", () => {
51
+ expect(replaceTokens("was ${not_a_token} now", tokens)).toBe(
52
+ "was ${not_a_token} now"
53
+ );
54
+ });
55
+
56
+ it("returns the string unchanged when there are no placeholders", () => {
57
+ expect(replaceTokens("plain text", tokens)).toBe("plain text");
58
+ });
59
+
60
+ it("accepts a JSON string source", () => {
61
+ expect(
62
+ replaceTokens("${price_tier1_300mbps}", JSON.stringify(tokens))
63
+ ).toBe("39.99");
64
+ });
65
+ });
66
+
67
+ describe("replaceTokens - empty / invalid sources", () => {
68
+ it.each<[string, TokenSource]>([
69
+ ["undefined", undefined],
70
+ ["null", null],
71
+ ["empty array", []],
72
+ ["blank string", " "],
73
+ ["malformed JSON", "not json"],
74
+ ["non-array JSON", JSON.stringify({ token: "x" })],
75
+ ])("returns input unchanged for %s source", (_label, source) => {
76
+ expect(replaceTokens("${price_tier1_300mbps}", source)).toBe(
77
+ "${price_tier1_300mbps}"
78
+ );
79
+ });
80
+
81
+ it("ignores entries without a usable token key or value", () => {
82
+ const source: TokenEntry[] = [
83
+ { value: "1" },
84
+ { token: "", value: "2" },
85
+ { token: "no_values" },
86
+ ];
87
+ expect(replaceTokens("${no_values}", source)).toBe("${no_values}");
88
+ });
89
+ });
90
+
91
+ describe("replaceTokens - malformed values and ReDoS safety", () => {
92
+ it("ignores entries whose resolved value is not a string (never throws)", () => {
93
+ const malformed = [
94
+ { token: "reward_50", value: { toString: null } },
95
+ { token: "price_x", promo: 123 },
96
+ ] as unknown as TokenEntry[];
97
+
98
+ expect(replaceTokens("${reward_50} / ${price_x}", malformed)).toBe(
99
+ "${reward_50} / ${price_x}"
100
+ );
101
+ });
102
+
103
+ it("falls back to value when promo is not a string", () => {
104
+ const entries = [
105
+ { token: "reward_50", promo: { bad: true }, value: "50" },
106
+ ] as unknown as TokenEntry[];
107
+
108
+ expect(replaceTokens("$${reward_50}", entries)).toBe("$50");
109
+ });
110
+
111
+ it("resolves pathological ${-heavy input quickly without backtracking", () => {
112
+ const evil = "${".repeat(50000);
113
+ const start = Date.now();
114
+ const result = replaceTokens(evil, tokens);
115
+
116
+ expect(result).toBe(evil);
117
+ expect(Date.now() - start).toBeLessThan(2000);
118
+ });
119
+
120
+ it("widens a replaced string result to string, not the literal input type", () => {
121
+ const result = replaceTokens("${reward_50}", tokens);
122
+ // Compiles only because `result` is `string`; a preserved literal type
123
+ // would make this comparison a TypeScript no-overlap error.
124
+ expect(result === "50").toBe(true);
125
+ });
126
+ });
127
+
128
+ describe("replaceTokens - empty / non-tokenizable input", () => {
129
+ it.each([
130
+ ["empty string", ""],
131
+ ["null", null],
132
+ ["undefined", undefined],
133
+ ])("returns %s input unchanged", (_label, input) => {
134
+ expect(replaceTokens(input as any, tokens)).toBe(input);
135
+ });
136
+
137
+ it("returns a non-document object unchanged", () => {
138
+ const obj = { foo: "bar" } as any;
139
+ expect(replaceTokens(obj, tokens)).toBe(obj);
140
+ });
141
+ });
142
+
143
+ describe("replaceTokens - rich text", () => {
144
+ it("replaces placeholders inside nested text nodes", () => {
145
+ const doc = makeDocument("Only ${price_tier1_300mbps}/mo");
146
+ const result = replaceTokens(doc, tokens) as RichTextDocument;
147
+
148
+ expect((result.content[0] as any).content[0].value).toBe("Only 39.99/mo");
149
+ });
150
+
151
+ it("does not mutate the original document", () => {
152
+ const doc = makeDocument("${price_tier1_300mbps}");
153
+ replaceTokens(doc, tokens);
154
+
155
+ expect((doc.content[0] as any).content[0].value).toBe(
156
+ "${price_tier1_300mbps}"
157
+ );
158
+ });
159
+
160
+ it("preserves marks and data on text nodes", () => {
161
+ const doc = makeDocument("${reward_50}");
162
+ (doc.content[0] as any).content[0].marks = [{ type: "bold" }];
163
+ const result = replaceTokens(doc, tokens) as RichTextDocument;
164
+
165
+ const textNode = (result.content[0] as any).content[0];
166
+ expect(textNode.value).toBe("50");
167
+ expect(textNode.marks).toEqual([{ type: "bold" }]);
168
+ });
169
+
170
+ it("returns the document unchanged when no tokens match", () => {
171
+ const doc = makeDocument("nothing to replace");
172
+ const result = replaceTokens(doc, tokens) as RichTextDocument;
173
+
174
+ expect((result.content[0] as any).content[0].value).toBe(
175
+ "nothing to replace"
176
+ );
177
+ });
178
+
179
+ it("leaves non-text leaf nodes (e.g. hr) untouched", () => {
180
+ const doc: RichTextDocument = {
181
+ nodeType: DOCUMENT,
182
+ content: [
183
+ { nodeType: HR, content: [] },
184
+ {
185
+ nodeType: PARAGRAPH,
186
+ content: [
187
+ { nodeType: "text", value: "${reward_50}", marks: [], data: {} },
188
+ ],
189
+ },
190
+ ],
191
+ };
192
+ const result = replaceTokens(doc, tokens) as RichTextDocument;
193
+
194
+ expect((result.content[0] as any).nodeType).toBe(HR);
195
+ expect((result.content[1] as any).content[0].value).toBe("50");
196
+ });
197
+ });