@drip-apex/sdk 0.1.0 → 0.2.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.
Files changed (245) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19262 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19246 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16621 -13539
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +27 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2070 -0
  128. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  129. package/src/consent-evaluation.ts +138 -0
  130. package/src/css.test.ts +30 -0
  131. package/src/css.ts +120 -6
  132. package/src/cwv.test.ts +60 -77
  133. package/src/debug-loader.test.ts +214 -0
  134. package/src/debug-loader.ts +41 -0
  135. package/src/decide-assignments.test.ts +196 -0
  136. package/src/decide-assignments.ts +204 -0
  137. package/src/delivery-timing.test.ts +104 -0
  138. package/src/delivery-timing.ts +89 -0
  139. package/src/early-interaction-capture.test.ts +451 -0
  140. package/src/early-interaction-capture.ts +230 -0
  141. package/src/early-reassert.test.ts +207 -0
  142. package/src/early-reassert.ts +152 -0
  143. package/src/edge-adoption.test.ts +435 -0
  144. package/src/edge-runtime.ts +30 -0
  145. package/src/editor/api.test.ts +109 -0
  146. package/src/editor/api.ts +34 -3
  147. package/src/editor/controller.test.ts +297 -14
  148. package/src/editor/controller.ts +69 -5
  149. package/src/editor/index.test.ts +2 -0
  150. package/src/editor/index.ts +1 -0
  151. package/src/editor/publication-result.ts +58 -0
  152. package/src/editor/types.ts +10 -1
  153. package/src/editor/view-kit.ts +2 -1
  154. package/src/editor/view.test.ts +108 -0
  155. package/src/editor/view.ts +49 -7
  156. package/src/endpoint.test.ts +15 -0
  157. package/src/endpoint.ts +10 -0
  158. package/src/exclusion-groups.ts +8 -18
  159. package/src/flags.test.ts +88 -1
  160. package/src/flags.ts +29 -29
  161. package/src/global-runtime-reconciler.test.ts +271 -0
  162. package/src/goal-eligibility.test.ts +29 -0
  163. package/src/goal-eligibility.ts +17 -0
  164. package/src/goals.test.ts +144 -1
  165. package/src/goals.ts +97 -18
  166. package/src/guards.test.ts +99 -1
  167. package/src/guards.ts +34 -15
  168. package/src/handover-cache-order.test.ts +6 -3
  169. package/src/handover.test.ts +154 -8
  170. package/src/handover.ts +122 -12
  171. package/src/hash.ts +29 -54
  172. package/src/holdout.test.ts +26 -0
  173. package/src/hydration.test.ts +73 -2
  174. package/src/hydration.ts +18 -9
  175. package/src/index.ts +130 -7287
  176. package/src/init-analytics.test.ts +1694 -0
  177. package/src/init-anti-flicker.test.ts +1211 -0
  178. package/src/init-cart-attributes.test.ts +2003 -0
  179. package/src/init-config-resolution.test.ts +999 -0
  180. package/src/init-consent.test.ts +971 -0
  181. package/src/init-empty.test.ts +11 -5406
  182. package/src/init-integrations.test.ts +846 -0
  183. package/src/init-page-activation.test.ts +5064 -0
  184. package/src/init-qa-loading.test.ts +740 -0
  185. package/src/init-redirects.test.ts +2156 -0
  186. package/src/init-test-helpers.ts +287 -0
  187. package/src/inline-bootstrap.test.ts +184 -0
  188. package/src/inline-bootstrap.ts +73 -0
  189. package/src/js.test.ts +51 -1
  190. package/src/js.ts +86 -2
  191. package/src/money.test.ts +55 -1
  192. package/src/money.ts +104 -2
  193. package/src/money.web-parity.contract.test.ts +85 -0
  194. package/src/mutate.test.ts +474 -10
  195. package/src/mutate.ts +739 -230
  196. package/src/page-trigger.test.ts +218 -8
  197. package/src/performance.ts +10 -0
  198. package/src/persistence-decoders.ts +18 -0
  199. package/src/public-api.ts +1828 -0
  200. package/src/qa-assistant-state.test.ts +249 -3
  201. package/src/qa-assistant-state.ts +146 -6
  202. package/src/qa-assistant-view.test.ts +220 -2
  203. package/src/qa-assistant-view.ts +102 -5
  204. package/src/react/index.test.tsx +37 -0
  205. package/src/react/index.tsx +19 -3
  206. package/src/reassert-sentinel.test.ts +155 -1
  207. package/src/reassert-sentinel.ts +168 -8
  208. package/src/redirect.test.ts +541 -0
  209. package/src/redirect.ts +572 -0
  210. package/src/replay-recorder-entry.ts +115 -0
  211. package/src/replay-signal-state.ts +100 -0
  212. package/src/replay-signals.test.ts +249 -0
  213. package/src/replay-signals.ts +151 -0
  214. package/src/replay.test.ts +764 -0
  215. package/src/replay.ts +514 -0
  216. package/src/router.test.ts +61 -2
  217. package/src/router.ts +50 -12
  218. package/src/runtime-state.ts +507 -0
  219. package/src/runtime.test.ts +174 -7
  220. package/src/runtime.ts +88 -14
  221. package/src/sanitize.test.ts +34 -0
  222. package/src/sanitize.ts +23 -12
  223. package/src/shadow.test.ts +187 -0
  224. package/src/shadow.ts +99 -0
  225. package/src/shopify-cart.test.ts +733 -23
  226. package/src/shopify-cart.ts +402 -49
  227. package/src/storefront-vitals.test.ts +217 -0
  228. package/src/storefront-vitals.ts +206 -0
  229. package/src/surface-signals.ts +6 -0
  230. package/src/targeting-conformance.test.ts +9 -0
  231. package/src/targeting-diagnostics.ts +38 -8
  232. package/src/targeting-parity-corpus.json +559 -0
  233. package/src/targeting-parity-corpus.test.ts +18 -0
  234. package/src/targeting.detailed.test.ts +105 -0
  235. package/src/targeting.test.ts +211 -0
  236. package/src/targeting.ts +88 -207
  237. package/src/track.test.ts +147 -0
  238. package/src/track.ts +125 -26
  239. package/src/types.ts +120 -13
  240. package/src/wait.test.ts +23 -0
  241. package/src/wait.ts +129 -13
  242. package/src/weekday-delivery.test.ts +56 -0
  243. package/src/weekday-schedule-parity-corpus.json +92 -0
  244. package/src/weekday-schedule.test.ts +69 -0
  245. package/src/weekday-schedule.ts +20 -0
package/src/js.ts CHANGED
@@ -2,6 +2,47 @@ import type { CustomJsPolicy } from "./types";
2
2
 
3
3
  let activePolicy: CustomJsPolicy = "hybrid";
4
4
  const EXTERNAL_JS_CLASS = "drip-external-js";
5
+ type ExternalJsJournalEntry = {
6
+ seq: number;
7
+ artifactId: string;
8
+ epoch: number;
9
+ owners: Set<string>;
10
+ target: Element;
11
+ state: "applied" | "invalidated";
12
+ };
13
+ const externalJsJournal: ExternalJsJournalEntry[] = [];
14
+ let externalJsSequence = 0;
15
+
16
+ function recordExternalJs(
17
+ element: Element,
18
+ artifactId: string,
19
+ ownerKeys: readonly string[],
20
+ epoch: number,
21
+ ): void {
22
+ const existing = externalJsJournal.find(
23
+ (entry) => entry.target === element && entry.artifactId === artifactId,
24
+ );
25
+ if (existing) {
26
+ ownerKeys.forEach((ownerKey) => existing.owners.add(ownerKey));
27
+ existing.state = "applied";
28
+ existing.epoch = epoch;
29
+ return;
30
+ }
31
+ externalJsJournal.push({
32
+ seq: ++externalJsSequence,
33
+ artifactId,
34
+ epoch,
35
+ owners: new Set(ownerKeys),
36
+ target: element,
37
+ state: "applied",
38
+ });
39
+ }
40
+
41
+ function forgetExternalJs(element: Element): void {
42
+ for (let index = externalJsJournal.length - 1; index >= 0; index -= 1) {
43
+ if (externalJsJournal[index].target === element) externalJsJournal.splice(index, 1);
44
+ }
45
+ }
5
46
 
6
47
  export function setJsPolicy(policy: CustomJsPolicy): void {
7
48
  activePolicy = policy;
@@ -29,7 +70,13 @@ export function execJs(code: string, sourceLabel?: string): void {
29
70
  }
30
71
  }
31
72
 
32
- export function loadExternalJs(url: string, sourceLabel: string): Promise<void> {
73
+ export function loadExternalJs(
74
+ url: string,
75
+ sourceLabel: string,
76
+ ownerKeys: readonly string[] = [],
77
+ artifactId = "js:external:" + sourceLabel,
78
+ epoch = 0,
79
+ ): Promise<void> {
33
80
  if (activePolicy === "strict") {
34
81
  if (typeof console !== "undefined") {
35
82
  console.warn("[drip] External JS blocked by strict policy for " + sourceLabel);
@@ -40,22 +87,59 @@ export function loadExternalJs(url: string, sourceLabel: string): Promise<void>
40
87
  const id = "drip-external-js-" + sourceLabel;
41
88
  const previous = document.getElementById(id);
42
89
  if (previous && previous.getAttribute("src") === url) {
90
+ recordExternalJs(previous, artifactId, ownerKeys, epoch);
43
91
  resolve();
44
92
  return;
45
93
  }
46
- if (previous) previous.remove();
94
+ if (previous) {
95
+ forgetExternalJs(previous);
96
+ previous.remove();
97
+ }
47
98
 
48
99
  const script = document.createElement("script");
49
100
  script.id = id;
50
101
  script.className = EXTERNAL_JS_CLASS;
51
102
  script.src = url;
52
103
  script.async = false;
104
+ recordExternalJs(script, artifactId, ownerKeys, epoch);
53
105
  script.onload = () => resolve();
54
106
  script.onerror = () => reject(new Error("Failed to load JS artifact: " + url));
55
107
  (document.head || document.documentElement).appendChild(script);
56
108
  });
57
109
  }
58
110
 
111
+ export function removeExternalJsForOwner(ownerKey: string, artifactId?: string): void {
112
+ const removed = new Set<Element>();
113
+ for (let index = externalJsJournal.length - 1; index >= 0; index -= 1) {
114
+ const entry = externalJsJournal[index];
115
+ if (artifactId !== undefined && entry.artifactId !== artifactId) continue;
116
+ if (!entry.owners.delete(ownerKey) || entry.owners.size > 0) continue;
117
+ removed.add(entry.target);
118
+ externalJsJournal.splice(index, 1);
119
+ }
120
+ for (const element of removed) {
121
+ if (!externalJsJournal.some((entry) => entry.target === element)) element.remove();
122
+ }
123
+ }
124
+
125
+ export function addExternalJsOwners(
126
+ existingOwnerKeys: ReadonlySet<string>,
127
+ ownerKeys: readonly string[],
128
+ artifactId?: string,
129
+ ): void {
130
+ if (existingOwnerKeys.size === 0 || ownerKeys.length === 0) return;
131
+ for (const entry of externalJsJournal) {
132
+ if (artifactId !== undefined && entry.artifactId !== artifactId) continue;
133
+ if (
134
+ !Array.from(existingOwnerKeys).some((ownerKey) =>
135
+ entry.owners.has(ownerKey)
136
+ )
137
+ ) continue;
138
+ ownerKeys.forEach((ownerKey) => entry.owners.add(ownerKey));
139
+ }
140
+ }
141
+
59
142
  export function removeAllExternalJs(): void {
60
143
  document.querySelectorAll("." + EXTERNAL_JS_CLASS).forEach((el) => el.remove());
144
+ externalJsJournal.length = 0;
61
145
  }
package/src/money.test.ts CHANGED
@@ -1,7 +1,34 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { formatCentsLike, parseDisplayedCents } from "./money";
2
+ import {
3
+ currencyExponent,
4
+ extractCurrencyTokens,
5
+ formatCentsLike,
6
+ parseDisplayedCents,
7
+ } from "./money";
8
+
9
+ describe("currency precision", () => {
10
+ it("uses the static ISO-4217 exponent table", () => {
11
+ expect(currencyExponent("JPY")).toBe(0);
12
+ expect(currencyExponent("UYI")).toBe(0);
13
+ expect(currencyExponent("EUR")).toBe(2);
14
+ expect(currencyExponent("BHD")).toBe(3);
15
+ expect(currencyExponent("KWD")).toBe(3);
16
+ expect(currencyExponent("CLF")).toBe(4);
17
+ expect(() => currencyExponent("ZZZ")).toThrow(/Unknown ISO-4217/);
18
+ });
19
+ });
3
20
 
4
21
  describe("money display parsing", () => {
22
+ it("keeps ISO-code boundaries and extracts a mid-string suffix token", () => {
23
+ expect(extractCurrencyTokens("ABCEUR 12,00")).toEqual([]);
24
+ expect(extractCurrencyTokens("EUR 12,00")).toEqual([
25
+ { symbol: "EUR", cents: 1200, raw: "EUR 12,00" },
26
+ ]);
27
+ expect(extractCurrencyTokens("Before 1.299,95 \u20ac after")).toEqual([
28
+ { symbol: "\u20ac", cents: 129995, raw: "1.299,95 \u20ac" },
29
+ ]);
30
+ });
31
+
5
32
  it.each([
6
33
  ["$1,299.00", 129900, "1,149.95"],
7
34
  ["€1.299,00", 129900, "1.149,95"],
@@ -29,6 +56,33 @@ describe("money display parsing", () => {
29
56
  expect(parsed && formatCentsLike(1123, parsed.convention)).toBe("1.123");
30
57
  });
31
58
 
59
+ it.each([
60
+ ["¥1,234", 0, 1234],
61
+ ["CLF 1,234", 4, 12_340_000],
62
+ ])(
63
+ "preserves grouped whole amounts at non-two-decimal scales: %s",
64
+ (text, minorUnits, expected) => {
65
+ const parsed = parseDisplayedCents(text, minorUnits);
66
+ expect(parsed?.cents).toBe(expected);
67
+ expect(parsed && formatCentsLike(expected, parsed.convention)).toContain(
68
+ "1,234",
69
+ );
70
+ },
71
+ );
72
+
73
+ it("keeps a mixed-separator fractional amount unambiguous", () => {
74
+ expect(parseDisplayedCents("KD 1.234,567", 3)?.cents).toBe(1_234_567);
75
+ });
76
+
77
+ it("keeps repeated comma groups unambiguous", () => {
78
+ expect(parseDisplayedCents("KD 1,234,567", 3)?.cents).toBe(1_234_567_000);
79
+ });
80
+
81
+ it("rejects a lone comma with three trailing digits for three-decimal currencies", () => {
82
+ expect(parseDisplayedCents("KD 1,234", 3)).toBeNull();
83
+ expect(parseDisplayedCents("$1,234", 2)?.cents).toBe(123_400);
84
+ });
85
+
32
86
  it.each(["10 and 20", "no digits"])("rejects ambiguous or absent amounts: %s", (text) => {
33
87
  expect(parseDisplayedCents(text)).toBeNull();
34
88
  });
package/src/money.ts CHANGED
@@ -12,6 +12,81 @@ export type DisplayedMoney = {
12
12
  end: number;
13
13
  };
14
14
 
15
+ export type CurrencyToken = {
16
+ symbol: string;
17
+ cents: number;
18
+ raw: string;
19
+ };
20
+
21
+ /** Extract marked displayed prices without treating ordinary numbers as money. */
22
+ export function extractCurrencyTokens(text: string): CurrencyToken[] {
23
+ const tokens: CurrencyToken[] = [];
24
+ const amount = String.raw`\d(?:[\d.,\u00a0 ]*\d)?`;
25
+ const symbol = String.raw`[$\u20ac\u00a3\u00a5\u20b9]`;
26
+ const pattern = new RegExp(
27
+ String.raw`(?:(${symbol})\s*(${amount})|(?:^|([^A-Z]))([A-Z]{3})\s*(${amount})|(${amount})\s*(${symbol}|[A-Z]{3}|kr)(?![A-Za-z]))`,
28
+ "g",
29
+ );
30
+ let match: RegExpExecArray | null;
31
+ while ((match = pattern.exec(text)) !== null) {
32
+ const consumedPrefix = match[3] ?? "";
33
+ const raw = match[0].slice(consumedPrefix.length);
34
+ const marker = match[1] ?? match[4] ?? match[7];
35
+ const displayedAmount = match[2] ?? match[5] ?? match[6];
36
+ if (/^[A-Z]{3}$/.test(marker) && !ISO_4217_CURRENCY_CODE_SET.has(marker)) {
37
+ continue;
38
+ }
39
+ const parsed = parseDisplayedCents(displayedAmount);
40
+ if (parsed) tokens.push({
41
+ symbol: marker,
42
+ cents: parsed.cents,
43
+ raw,
44
+ });
45
+ }
46
+ return tokens;
47
+ }
48
+ export const ISO_4217_CURRENCY_CODES =
49
+ `AED AFN ALL AMD ANG AOA ARS AUD AWG AZN BAM BBD BDT BGN BHD BIF BMD BND BOB BOV BRL BSD BTN BWP BYN BZD CAD CDF CHE CHF CHW CLF CLP CNY COP COU CRC CUP CVE CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD HKD HNL HRK HTG HUF IDR ILS INR IQD IRR ISK JMD JOD JPY KES KGS KHR KMF KPW KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN MXV MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR PLN PYG QAR RON RSD RUB RWF SAR SBD SCR SDG SEK SGD SHP SLE SOS SRD SSP STN SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD TZS UAH UGX USD USN UYI UYU UYW UZS VES VND VUV WST XAF XCD XCG XDR XOF XPF XSU YER ZAR ZMW ZWG`.split(
50
+ " ",
51
+ );
52
+ const ISO_4217_CURRENCY_CODE_SET = new Set(ISO_4217_CURRENCY_CODES);
53
+
54
+ // Precision is a read-compatibility contract as well as a write-time concern.
55
+ // Keep withdrawn codes that can exist on stored shops/experiments here even
56
+ // though the assigned-code set above intentionally rejects them on new writes.
57
+ const ISO_4217_PRECISION_CODE_SET = new Set([
58
+ ...ISO_4217_CURRENCY_CODES,
59
+ "CUC",
60
+ "SLL",
61
+ "ZWL",
62
+ ]);
63
+
64
+ /** Returns whether a code is currently active in ISO 4217. */
65
+ export function isIso4217CurrencyCode(currencyCode: string): boolean {
66
+ return ISO_4217_CURRENCY_CODE_SET.has(currencyCode.trim().toUpperCase());
67
+ }
68
+
69
+ const ZERO_DECIMAL_CURRENCIES = new Set(
70
+ `BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF`.split(
71
+ " ",
72
+ ),
73
+ );
74
+ const THREE_DECIMAL_CURRENCIES = new Set(
75
+ `BHD IQD JOD KWD LYD OMR TND`.split(" "),
76
+ );
77
+
78
+ /** Authoritative ISO-4217 minor-unit exponent for editor-supported currencies. */
79
+ export function currencyExponent(currencyCode: string): number {
80
+ if (!ISO_4217_PRECISION_CODE_SET.has(currencyCode)) {
81
+ throw new RangeError(
82
+ `Unknown ISO-4217 currency precision: ${currencyCode}`,
83
+ );
84
+ }
85
+ if (ZERO_DECIMAL_CURRENCIES.has(currencyCode)) return 0;
86
+ if (THREE_DECIMAL_CURRENCIES.has(currencyCode)) return 3;
87
+ if (currencyCode === "CLF" || currencyCode === "UYW") return 4;
88
+ return 2;
89
+ }
15
90
  export function parseDisplayedCents(text: string, minorUnits = 2): DisplayedMoney | null {
16
91
  const pattern = /\d(?:[\d.,\u00a0 ]*\d)?/g;
17
92
  const match = pattern.exec(text);
@@ -20,7 +95,35 @@ export function parseDisplayedCents(text: string, minorUnits = 2): DisplayedMone
20
95
  const compact = raw.replace(/[ \u00a0]/g, "");
21
96
  const separator = Math.max(compact.lastIndexOf("."), compact.lastIndexOf(","));
22
97
  const trailing = separator < 0 ? 0 : compact.length - separator - 1;
23
- const decimals = trailing && trailing <= minorUnits ? trailing : 0;
98
+ const separatorCharacter = separator < 0 ? "" : compact[separator];
99
+ const repeatedSeparator =
100
+ separatorCharacter !== "" && compact.indexOf(separatorCharacter) !== separator;
101
+ const hasOtherSeparator =
102
+ separatorCharacter === ","
103
+ ? compact.includes(".")
104
+ : separatorCharacter === "." && compact.includes(",");
105
+ // A lone comma before exactly three digits can be either a decimal mark or
106
+ // a thousands separator in a three-decimal currency. Fail closed unless
107
+ // another separator provides enough evidence to select one convention.
108
+ if (
109
+ minorUnits === 3 &&
110
+ trailing === 3 &&
111
+ separatorCharacter === "," &&
112
+ !repeatedSeparator &&
113
+ !hasOtherSeparator
114
+ ) {
115
+ return null;
116
+ }
117
+ // Scaled-currency themes can omit a zero fractional part. Preserve a
118
+ // comma-separated three-digit group as a whole amount unless another
119
+ // separator makes the fractional convention explicit.
120
+ const groupedWhole =
121
+ trailing === 3 &&
122
+ minorUnits !== 2 &&
123
+ !hasOtherSeparator &&
124
+ (separatorCharacter === "," || repeatedSeparator);
125
+ const decimals =
126
+ trailing && trailing <= minorUnits && !groupedWhole ? trailing : 0;
24
127
 
25
128
  const visibleDecimal = decimals ? compact[separator] as "." | "," : null;
26
129
  const groupMatch = raw.slice(0, visibleDecimal ? raw.lastIndexOf(visibleDecimal) : raw.length).match(/[.,\u00a0 ]/);
@@ -40,7 +143,6 @@ export function parseDisplayedCents(text: string, minorUnits = 2): DisplayedMone
40
143
  end: match.index + raw.length,
41
144
  };
42
145
  }
43
-
44
146
  export function formatCentsLike(cents: number, convention: MoneyConvention): string {
45
147
  const scale = 10 ** convention.minorUnits;
46
148
  let major = String(Math.floor(cents / scale));
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ currencyExponent as sdkCurrencyExponent,
4
+ extractCurrencyTokens as extractSdkCurrencyTokens,
5
+ isIso4217CurrencyCode as isSdkIso4217CurrencyCode,
6
+ ISO_4217_CURRENCY_CODES,
7
+ parseDisplayedCents as parseSdkDisplayedCents,
8
+ } from "./money";
9
+ import {
10
+ currencyExponent as webCurrencyExponent,
11
+ extractCurrencyTokens as extractWebCurrencyTokens,
12
+ isIso4217CurrencyCode as isWebIso4217CurrencyCode,
13
+ parseDisplayedCents as parseWebDisplayedCents,
14
+ } from "../../../apps/web/src/lib/commerce-editors/money";
15
+
16
+ const currencyVectors = [
17
+ ...ISO_4217_CURRENCY_CODES,
18
+ "CUC",
19
+ "SLL",
20
+ "ZWL",
21
+ "XXX",
22
+ "ZZZ",
23
+ "usd",
24
+ "",
25
+ ];
26
+
27
+ const displayedMoneyVectors: Array<[text: string, minorUnits: number]> = [
28
+ ["0", 0],
29
+ ["0", 2],
30
+ ["0.005", 3],
31
+ ["USD 0.005", 3],
32
+ ["$0.005", 3],
33
+ ["1.005", 2],
34
+ ["1.005", 3],
35
+ ["1,005", 3],
36
+ ["-1.005", 3],
37
+ ["JPY 123456789", 0],
38
+ ["USD 123456789.99", 2],
39
+ ["BHD 123456789.999", 3],
40
+ ["123456789.999 BHD", 3],
41
+ ["€1,234.56", 2],
42
+ ["1.234,56 EUR", 2],
43
+ ["1 234,56 kr", 2],
44
+ ["USD 10 and USD 20", 2],
45
+ ["not money", 2],
46
+ ["ZZZ 10.00", 2],
47
+ ];
48
+
49
+ function resultOf<T>(callback: () => T): { value: T } | { error: string } {
50
+ try {
51
+ return { value: callback() };
52
+ } catch (error) {
53
+ return {
54
+ error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
55
+ };
56
+ }
57
+ }
58
+
59
+ describe("web money behavior parity", () => {
60
+ it.each(currencyVectors)("resolves currency exponent %j identically", (code) => {
61
+ expect(resultOf(() => webCurrencyExponent(code))).toEqual(
62
+ resultOf(() => sdkCurrencyExponent(code)),
63
+ );
64
+ });
65
+
66
+ it.each(currencyVectors)("recognizes ISO currency %j identically", (code) => {
67
+ expect(isWebIso4217CurrencyCode(code)).toBe(isSdkIso4217CurrencyCode(code));
68
+ });
69
+
70
+ it.each(displayedMoneyVectors)(
71
+ "parses displayed amount %j at exponent %i identically",
72
+ (text, minorUnits) => {
73
+ expect(parseWebDisplayedCents(text, minorUnits)).toEqual(
74
+ parseSdkDisplayedCents(text, minorUnits),
75
+ );
76
+ },
77
+ );
78
+
79
+ it.each(displayedMoneyVectors)(
80
+ "extracts currency tokens from %j identically",
81
+ (text) => {
82
+ expect(extractWebCurrencyTokens(text)).toEqual(extractSdkCurrencyTokens(text));
83
+ },
84
+ );
85
+ });