@windstream/react-shared-components 0.2.83 → 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/dist/core.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/utils/index.d.ts +16 -2
- package/dist/utils/index.esm.js +1 -1
- package/dist/utils/index.esm.js.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/utils/index.test.ts +5 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/replace-tokens.test.ts +197 -0
- package/src/utils/replace-tokens.ts +89 -0
package/package.json
CHANGED
package/src/utils/index.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type TokenEntry = {
|
|
2
|
+
token?: string;
|
|
3
|
+
value?: string;
|
|
4
|
+
promo?: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type TokenSource = TokenEntry[] | string | null | undefined;
|
|
9
|
+
|
|
10
|
+
export type RichTextDocument = {
|
|
11
|
+
nodeType: string;
|
|
12
|
+
content: unknown[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ReplaceTokensResult<T> = T extends string ? string : T;
|
|
16
|
+
|
|
17
|
+
const TOKEN_PATTERN = /\$\{([^${}]+)\}/g;
|
|
18
|
+
|
|
19
|
+
const normalizeEntries = (tokens: TokenSource): TokenEntry[] => {
|
|
20
|
+
if (Array.isArray(tokens)) return tokens;
|
|
21
|
+
if (typeof tokens === "string" && tokens.trim()) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(tokens);
|
|
24
|
+
return Array.isArray(parsed) ? (parsed as TokenEntry[]) : [];
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const buildTokenMap = (tokens: TokenSource): Record<string, string> =>
|
|
33
|
+
normalizeEntries(tokens).reduce<Record<string, string>>((acc, entry) => {
|
|
34
|
+
if (entry && typeof entry.token === "string" && entry.token) {
|
|
35
|
+
const promo = typeof entry.promo === "string" ? entry.promo : undefined;
|
|
36
|
+
const value = typeof entry.value === "string" ? entry.value : undefined;
|
|
37
|
+
const resolved = promo ?? value;
|
|
38
|
+
if (resolved !== undefined) acc[entry.token] = resolved;
|
|
39
|
+
}
|
|
40
|
+
return acc;
|
|
41
|
+
}, {});
|
|
42
|
+
|
|
43
|
+
const replaceInString = (input: string, map: Record<string, string>): string =>
|
|
44
|
+
input.replace(TOKEN_PATTERN, (match, token) =>
|
|
45
|
+
Object.prototype.hasOwnProperty.call(map, token) ? map[token] : match
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const isRichTextDocument = (value: unknown): value is RichTextDocument =>
|
|
49
|
+
!!value &&
|
|
50
|
+
typeof value === "object" &&
|
|
51
|
+
"nodeType" in (value as Record<string, unknown>) &&
|
|
52
|
+
"content" in (value as Record<string, unknown>);
|
|
53
|
+
|
|
54
|
+
const replaceInNode = <T>(node: T, map: Record<string, string>): T => {
|
|
55
|
+
if (!node || typeof node !== "object") return node;
|
|
56
|
+
const current = node as Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
if (current.nodeType === "text" && typeof current.value === "string") {
|
|
59
|
+
return { ...current, value: replaceInString(current.value, map) } as T;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (Array.isArray(current.content)) {
|
|
63
|
+
return {
|
|
64
|
+
...current,
|
|
65
|
+
content: current.content.map(child => replaceInNode(child, map)),
|
|
66
|
+
} as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return node;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function replaceTokens<
|
|
73
|
+
T extends string | RichTextDocument | null | undefined,
|
|
74
|
+
>(input: T, tokens: TokenSource): ReplaceTokensResult<T> {
|
|
75
|
+
if (input == null || input === "") return input as ReplaceTokensResult<T>;
|
|
76
|
+
|
|
77
|
+
const map = buildTokenMap(tokens);
|
|
78
|
+
if (Object.keys(map).length === 0) return input as ReplaceTokensResult<T>;
|
|
79
|
+
|
|
80
|
+
if (typeof input === "string") {
|
|
81
|
+
return replaceInString(input, map) as ReplaceTokensResult<T>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isRichTextDocument(input)) {
|
|
85
|
+
return replaceInNode(input, map) as ReplaceTokensResult<T>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return input as ReplaceTokensResult<T>;
|
|
89
|
+
}
|