@decocms/apps-website 7.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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * OneDollarStats — deco's lightweight in-house analytics.
3
+ *
4
+ * Posts pageviews (initial load + SPA navigations) and forwards DECO
5
+ * events to the lilstts collector. Mount once in `__root.tsx` as a child
6
+ * of `DecoRootLayout`:
7
+ *
8
+ * ```tsx
9
+ * <DecoRootLayout … >
10
+ * <OneDollarStats />
11
+ * </DecoRootLayout>
12
+ * ```
13
+ *
14
+ * The component is env-gated and self-mounting — no CMS wiring needed.
15
+ *
16
+ * ## Why this design
17
+ *
18
+ * 1. **We own pageviews.** The lilstts SDK has its own auto-pageview path
19
+ * (driven by `history.pushState` wrapping). We disable it via
20
+ * `data-autocollect="false"` and call `window.stonks.view(flags)`
21
+ * ourselves. This is the only way to attach `deco_segment` cookie
22
+ * flags to pageviews — the SDK's auto-path doesn't know about them.
23
+ *
24
+ * 2. **`useEffect` for client logic.** All side-effects (initial pageview,
25
+ * pushState wrap, DECO event subscribe) run inside a `useEffect`,
26
+ * which fires after hydration. By then `<ScriptOnce>` in
27
+ * `DecoRootLayout` has bootstrapped `window.DECO.events`, and the SDK
28
+ * `<script>` (rendered as a sibling) has loaded and set
29
+ * `window.stonks`. No inline `dangerouslySetInnerHTML` snippet, no
30
+ * fragile script-execution-order dependency.
31
+ *
32
+ * 3. **Module-level guards.** `window.DECO.events.subscribe()` returns no
33
+ * unsubscribe handle, so we cannot clean up on unmount. We use a
34
+ * module-level `initialized` flag to ensure init runs exactly once
35
+ * per page lifetime, surviving HMR and React StrictMode double-mount.
36
+ *
37
+ * 4. **Bounded readiness polling.** `window.stonks` and `window.DECO`
38
+ * might not be ready the instant our effect fires (race with script
39
+ * load). We poll every 50 ms for up to 10 s. Production: resolves
40
+ * within one tick.
41
+ *
42
+ * ## Behavioural parity vs Fresh `deco-cx/apps`
43
+ *
44
+ * Mirrors the Path B snippet (`analytics/loaders/OneDollarScript.ts`):
45
+ * unconditional first pageview with flag enrichment, SPA nav tracking,
46
+ * and DECO event forwarding. Diverges from the Fresh component variant
47
+ * (which depended on a synthesised `{ name: "deco" }` event from
48
+ * `Events.tsx`'s subscribe-replay — no equivalent in TanStack).
49
+ *
50
+ * `pageId` enrichment is intentionally dropped — no admin dashboard
51
+ * consumes it. Add later if a flag-segmented dashboard needs it.
52
+ */
53
+
54
+ import { useEffect } from "react";
55
+
56
+ declare global {
57
+ interface Window {
58
+ stonks?: {
59
+ view?: (params?: Record<string, string | boolean | number>) => void;
60
+ event?: (name: string, params?: Record<string, string | boolean | number>) => void;
61
+ };
62
+ }
63
+ }
64
+
65
+ export interface Props {
66
+ /** lilstts collector URL. Defaults to {@link DEFAULT_COLLECTOR_ADDRESS}. */
67
+ collectorAddress?: string;
68
+ /** lilstts static script URL. Defaults to {@link DEFAULT_ANALYTICS_SCRIPT_URL}. */
69
+ staticScriptUrl?: string;
70
+ }
71
+
72
+ export const DEFAULT_COLLECTOR_ADDRESS = "https://d.lilstts.com/events";
73
+ export const DEFAULT_ANALYTICS_SCRIPT_URL = "https://s.lilstts.com/deco.js";
74
+
75
+ /**
76
+ * Set `ONEDOLLAR_ENABLED=false` on the Worker to disable. Default: enabled.
77
+ * Matches the Fresh-side Deno env contract.
78
+ */
79
+ const ONEDOLLAR_ENABLED = process.env.ONEDOLLAR_ENABLED !== "false";
80
+ const ONEDOLLAR_COLLECTOR = process.env.ONEDOLLAR_COLLECTOR;
81
+ const ONEDOLLAR_STATIC_SCRIPT = process.env.ONEDOLLAR_STATIC_SCRIPT;
82
+
83
+ function OneDollarStats({ collectorAddress, staticScriptUrl }: Props) {
84
+ if (!ONEDOLLAR_ENABLED) return null;
85
+
86
+ const collector = collectorAddress ?? ONEDOLLAR_COLLECTOR ?? DEFAULT_COLLECTOR_ADDRESS;
87
+ const staticScript = staticScriptUrl ?? ONEDOLLAR_STATIC_SCRIPT ?? DEFAULT_ANALYTICS_SCRIPT_URL;
88
+
89
+ return (
90
+ <>
91
+ <link rel="dns-prefetch" href={collector} />
92
+ <link rel="preconnect" href={collector} crossOrigin="anonymous" />
93
+ <script
94
+ id="onedollarstats-tracker"
95
+ data-autocollect="false"
96
+ data-hash-routing="true"
97
+ data-url={collector}
98
+ src={staticScript}
99
+ defer
100
+ />
101
+ <OneDollarStatsClient />
102
+ </>
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Client-only side-effects. Mounted as a child of {@link OneDollarStats};
108
+ * does not render any DOM.
109
+ */
110
+ function OneDollarStatsClient() {
111
+ useEffect(() => {
112
+ initOneDollarStats();
113
+ }, []);
114
+ return null;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Module-level state — survives StrictMode double-mount and HMR remounts.
119
+ // ---------------------------------------------------------------------------
120
+
121
+ let initialized = false;
122
+ let cachedFlags: Record<string, boolean> | null = null;
123
+
124
+ interface DecoSegmentCookie {
125
+ active?: string[];
126
+ inactiveDrawn?: string[];
127
+ }
128
+
129
+ /**
130
+ * Read A/B test flags from the `deco_segment` cookie. Cached after first
131
+ * read for the lifetime of the page — flags are baked at request time
132
+ * server-side and don't change mid-session.
133
+ *
134
+ * Exported for testing.
135
+ */
136
+ export function readFlagsFromCookie(
137
+ cookieString: string = typeof document !== "undefined" ? document.cookie : "",
138
+ ): Record<string, boolean> {
139
+ if (cachedFlags && cookieString === (typeof document !== "undefined" ? document.cookie : "")) {
140
+ return cachedFlags;
141
+ }
142
+ const flags: Record<string, boolean> = {};
143
+ try {
144
+ const cookies = parseCookies(cookieString);
145
+ const raw = cookies.deco_segment;
146
+ if (raw) {
147
+ const seg = JSON.parse(decodeURIComponent(atob(raw))) as DecoSegmentCookie;
148
+ for (const name of seg.active ?? []) flags[name] = true;
149
+ for (const name of seg.inactiveDrawn ?? []) flags[name] = false;
150
+ }
151
+ } catch {
152
+ // Malformed cookie — proceed with empty flags rather than crashing analytics.
153
+ }
154
+ cachedFlags = flags;
155
+ return flags;
156
+ }
157
+
158
+ function parseCookies(cookieString: string): Record<string, string> {
159
+ return cookieString.split(";").reduce<Record<string, string>>((acc, c) => {
160
+ const idx = c.indexOf("=");
161
+ if (idx > 0) acc[c.slice(0, idx).trim()] = c.slice(idx + 1).trim();
162
+ return acc;
163
+ }, {});
164
+ }
165
+
166
+ /**
167
+ * Truncate any value to the lilstts payload limit (~1 KB per field).
168
+ * Exported for testing.
169
+ */
170
+ export function truncate(v: unknown): string {
171
+ const s = typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v);
172
+ return s.slice(0, 990);
173
+ }
174
+
175
+ /**
176
+ * Poll for a global to become available, then invoke `cb` exactly once.
177
+ * Bounded by `maxAttempts * intervalMs` (default ~10 s). On timeout, no-op.
178
+ */
179
+ function whenReady<T>(
180
+ check: () => T | undefined,
181
+ cb: (value: T) => void,
182
+ { intervalMs = 50, maxAttempts = 200 }: { intervalMs?: number; maxAttempts?: number } = {},
183
+ ): void {
184
+ const initial = check();
185
+ if (initial !== undefined) {
186
+ cb(initial);
187
+ return;
188
+ }
189
+ let attempts = 0;
190
+ const iv = setInterval(() => {
191
+ attempts++;
192
+ const v = check();
193
+ if (v !== undefined) {
194
+ clearInterval(iv);
195
+ cb(v);
196
+ } else if (attempts >= maxAttempts) {
197
+ clearInterval(iv);
198
+ }
199
+ }, intervalMs);
200
+ }
201
+
202
+ /**
203
+ * Wire up the analytics integration. Idempotent — only the first call has
204
+ * any effect.
205
+ *
206
+ * @internal exported for tests; do not call from app code.
207
+ */
208
+ export function initOneDollarStats(): void {
209
+ if (initialized) return;
210
+ initialized = true;
211
+
212
+ const flags = readFlagsFromCookie();
213
+
214
+ // 1) Initial pageview + SPA nav tracking, with flag enrichment.
215
+ whenReady(
216
+ () =>
217
+ typeof window.stonks?.view === "function"
218
+ ? window.stonks.view.bind(window.stonks)
219
+ : undefined,
220
+ (view) => {
221
+ view(flags);
222
+ wrapHistoryPushState(() => view(flags));
223
+ addEventListener("popstate", () => view(flags));
224
+ },
225
+ );
226
+
227
+ // 2) Forward DECO events to stonks.event with flag enrichment.
228
+ whenReady(
229
+ () =>
230
+ typeof window.DECO?.events?.subscribe === "function"
231
+ ? window.DECO.events.subscribe.bind(window.DECO.events)
232
+ : undefined,
233
+ (subscribe) => {
234
+ subscribe((event: { name?: string; params?: Record<string, unknown> } | null | undefined) => {
235
+ if (!event || !event.name || event.name === "deco") return;
236
+ if (typeof window.stonks?.event !== "function") return;
237
+ const values: Record<string, string | boolean | number> = { ...flags };
238
+ for (const [k, v] of Object.entries(event.params ?? {})) {
239
+ if (v == null) continue;
240
+ values[k] = truncate(v);
241
+ }
242
+ window.stonks.event(event.name, values);
243
+ });
244
+ },
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Wrap `history.pushState` to invoke `onPush` after each call. Idempotent
250
+ * via a marker property on the wrapper. The lilstts SDK installs its own
251
+ * wrapper too — with `data-autocollect="false"` its handler is a no-op,
252
+ * so we don't double-fire.
253
+ */
254
+ function wrapHistoryPushState(onPush: () => void): void {
255
+ const ANY_HISTORY = history as History & { __onedollarstats_wrapped?: true };
256
+ if (ANY_HISTORY.__onedollarstats_wrapped) return;
257
+ const original = history.pushState;
258
+ const wrapped = function (this: History, ...args: Parameters<History["pushState"]>): void {
259
+ original.apply(this, args);
260
+ try {
261
+ onPush();
262
+ } catch (err) {
263
+ console.error("[OneDollarStats] pushState handler", err);
264
+ }
265
+ } as History["pushState"];
266
+ (wrapped as unknown as { __onedollarstats_wrapped: true }).__onedollarstats_wrapped = true;
267
+ history.pushState = wrapped;
268
+ ANY_HISTORY.__onedollarstats_wrapped = true;
269
+ }
270
+
271
+ /**
272
+ * @internal — reset module state for tests. NEVER call from app code.
273
+ */
274
+ export function __resetForTests(): void {
275
+ initialized = false;
276
+ cachedFlags = null;
277
+ if (typeof history !== "undefined") {
278
+ const h = history as History & { __onedollarstats_wrapped?: true };
279
+ delete h.__onedollarstats_wrapped;
280
+ }
281
+ }
282
+
283
+ export default OneDollarStats;
@@ -0,0 +1,139 @@
1
+ import type { ImageWidget, OGType } from "../types";
2
+ import { stripHTML } from "../utils/html";
3
+
4
+ export const renderTemplateString = (template: string, value: string) =>
5
+ template.replace("%s", value);
6
+
7
+ export type SEOSection = React.JSX.Element;
8
+
9
+ export interface Props {
10
+ title?: string;
11
+ /**
12
+ * @title Title template
13
+ * @description add a %s whenever you want it to be replaced with the product name, category name or search term
14
+ * @default %s
15
+ */
16
+ titleTemplate?: string;
17
+ description?: string;
18
+ /**
19
+ * @title Description template
20
+ * @description add a %s whenever you want it to be replaced with the product name, category name or search term
21
+ * @default %s
22
+ */
23
+ descriptionTemplate?: string;
24
+ /** @default website */
25
+ type?: OGType;
26
+ /** @description Recommended: 1200 x 630 px (up to 5MB) */
27
+ image?: ImageWidget;
28
+ /** @description Recommended: 16 x 16 px */
29
+ favicon?: ImageWidget;
30
+ /** @description Suggested color that browsers should use to customize the display of the page or of the surrounding user interface */
31
+ themeColor?: string;
32
+ /** @title Canonical URL */
33
+ canonical?: string;
34
+ /**
35
+ * @title Disable indexing
36
+ * @description In testing, you can use this to prevent search engines from indexing your site
37
+ */
38
+ noIndexing?: boolean;
39
+
40
+ /** @title Open Graph Config */
41
+ openGraphConfig?: {
42
+ title?: string;
43
+ description?: string;
44
+ image?: ImageWidget;
45
+ url?: string;
46
+ };
47
+
48
+ /** @title Twitter Config */
49
+ twitterConfig?: {
50
+ title?: string;
51
+ description?: string;
52
+ image?: ImageWidget;
53
+ };
54
+
55
+ jsonLDs?: unknown[];
56
+ }
57
+
58
+ /**
59
+ * SEO component — renders meta tags that React 19 / TanStack Start
60
+ * automatically hoists into <head>.
61
+ */
62
+ function Seo({
63
+ title: t = "",
64
+ titleTemplate = "%s",
65
+ description: desc,
66
+ descriptionTemplate = "%s",
67
+ type,
68
+ image,
69
+ favicon,
70
+ themeColor,
71
+ canonical,
72
+ noIndexing,
73
+ openGraphConfig,
74
+ twitterConfig,
75
+ jsonLDs = [],
76
+ }: Props) {
77
+ const twitterCard = type === "website" ? "summary" : "summary_large_image";
78
+
79
+ const title = stripHTML(t);
80
+ const description = stripHTML(desc || "");
81
+
82
+ const twitterImage = twitterConfig?.image ?? image;
83
+ const twitterTitle = twitterConfig?.title ? stripHTML(twitterConfig.title) : title;
84
+ const twitterDescription = twitterConfig?.description
85
+ ? stripHTML(twitterConfig.description)
86
+ : description;
87
+
88
+ const openGraphImage = openGraphConfig?.image ?? image;
89
+ const openGraphTitle = openGraphConfig?.title ? stripHTML(openGraphConfig.title) : title;
90
+ const openGraphDescription = openGraphConfig?.description
91
+ ? stripHTML(openGraphConfig.description)
92
+ : description;
93
+
94
+ return (
95
+ <>
96
+ <title>{renderTemplateString(titleTemplate, title)}</title>
97
+ <meta name="description" content={renderTemplateString(descriptionTemplate, description)} />
98
+ <meta name="theme-color" content={themeColor} />
99
+ <link rel="icon" href={favicon} />
100
+
101
+ {/* Twitter tags */}
102
+ <meta property="twitter:title" content={twitterTitle} />
103
+ <meta property="twitter:description" content={twitterDescription} />
104
+ <meta property="twitter:image" content={twitterImage} />
105
+ <meta property="twitter:card" content={twitterCard} />
106
+
107
+ {/* OpenGraph tags */}
108
+ <meta property="og:title" content={openGraphTitle} />
109
+ <meta property="og:description" content={openGraphDescription} />
110
+ <meta property="og:type" content={type} />
111
+ <meta property="og:image" content={openGraphImage} />
112
+ {Boolean(openGraphConfig?.url || canonical) && (
113
+ <meta property="og:url" content={openGraphConfig?.url ?? canonical} />
114
+ )}
115
+
116
+ {/* Link tags */}
117
+ {canonical && <link rel="canonical" href={canonical} />}
118
+
119
+ {/* No index, no follow */}
120
+ {noIndexing && <meta name="robots" content="noindex, nofollow" />}
121
+ {!noIndexing && <meta name="robots" content="index, follow" />}
122
+
123
+ {jsonLDs.map((json, idx) => (
124
+ <script
125
+ key={idx}
126
+ type="application/ld+json"
127
+ dangerouslySetInnerHTML={{
128
+ __html: JSON.stringify({
129
+ "@context": "https://schema.org",
130
+ ...(json as Record<string, unknown>),
131
+ }),
132
+ }}
133
+ />
134
+ ))}
135
+ </>
136
+ );
137
+ }
138
+
139
+ export default Seo;
@@ -0,0 +1,47 @@
1
+ import { useId } from "react";
2
+ import type { Font, Variable } from "../types";
3
+
4
+ export interface Props {
5
+ variables?: Variable[];
6
+ fonts?: Font[];
7
+ colorScheme?: "light" | "dark";
8
+ }
9
+
10
+ const withPrefersColorScheme = (scheme: "light" | "dark", css: string) =>
11
+ `@media (prefers-color-scheme: ${scheme}) { ${css} }`;
12
+
13
+ /**
14
+ * Theme component — injects CSS custom properties and font stylesheets.
15
+ * React 19 / TanStack Start automatically hoists <style> into <head>.
16
+ */
17
+ function Theme({ fonts = [], variables = [], colorScheme }: Props) {
18
+ const id = useId();
19
+
20
+ const family = fonts.reduce((acc, { family }) => (acc ? `${acc}, ${family}` : family), "");
21
+
22
+ const vars = [{ name: "--font-family", value: family }, ...variables]
23
+ .map(({ name, value }) => `${name}: ${value}`)
24
+ .join(";");
25
+
26
+ const css = `* {${vars}}`;
27
+ const html = colorScheme ? withPrefersColorScheme(colorScheme, css) : css;
28
+
29
+ return (
30
+ <>
31
+ {fonts?.map(({ styleSheet }, idx) =>
32
+ styleSheet ? (
33
+ <style key={idx} type="text/css" dangerouslySetInnerHTML={{ __html: styleSheet }} />
34
+ ) : null,
35
+ )}
36
+ {html && (
37
+ <style
38
+ type="text/css"
39
+ id={`__DESIGN_SYSTEM_VARS-${id}`}
40
+ dangerouslySetInnerHTML={{ __html: html }}
41
+ />
42
+ )}
43
+ </>
44
+ );
45
+ }
46
+
47
+ export default Theme;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * TODO: Implement video preload with link[rel="preload"] tags once
3
+ * browsers support it. More info at: https://stackoverflow.com/a/68368601
4
+ */
5
+ import { forwardRef } from "react";
6
+
7
+ import { getOptimizedMediaUrl } from "@decocms/blocks/hooks";
8
+
9
+ export interface Props {
10
+ src: string;
11
+ /** @description Improves Web Vitals (CLS|LCP) */
12
+ width: number;
13
+ /** @description Improves Web Vitals (CLS|LCP) */
14
+ height: number;
15
+ /** @description Force video through the optimization engine */
16
+ forceOptimizedSrc?: boolean;
17
+ className?: string;
18
+ style?: React.CSSProperties;
19
+ autoPlay?: boolean;
20
+ loop?: boolean;
21
+ muted?: boolean;
22
+ playsInline?: boolean;
23
+ controls?: boolean;
24
+ poster?: string;
25
+ }
26
+
27
+ const Video = forwardRef<HTMLVideoElement, Props>((props, ref) => {
28
+ const { forceOptimizedSrc, src: originalSrc, width, height, ...rest } = props;
29
+
30
+ const src = forceOptimizedSrc
31
+ ? getOptimizedMediaUrl({
32
+ originalSrc,
33
+ width,
34
+ height,
35
+ fit: "cover",
36
+ })
37
+ : originalSrc;
38
+
39
+ return <video {...rest} preload={undefined} src={src} width={width} height={height} ref={ref} />;
40
+ });
41
+
42
+ Video.displayName = "Video";
43
+
44
+ export default Video;
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Public API for the website app.
3
+ */
4
+
5
+ export { configureWebsite, getWebsiteConfig } from "./client";
6
+ // App
7
+ export { configure } from "./mod";
8
+
9
+ // Types
10
+ export type {
11
+ FlagObj,
12
+ Font,
13
+ ImageWidget,
14
+ MatchContext,
15
+ Matcher,
16
+ MultivariateFlag,
17
+ OGType,
18
+ SeoConfig,
19
+ Variable,
20
+ Variant,
21
+ WebsiteConfig,
22
+ } from "./types";
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import EnvironmentLoader from "./environment";
3
+
4
+ describe("EnvironmentLoader", () => {
5
+ it("reads from process.env when name is set", () => {
6
+ process.env.MY_ENV_VAR = "hello";
7
+ const result = EnvironmentLoader({ value: "fallback", name: "MY_ENV_VAR" });
8
+ expect(result.get()).toBe("hello");
9
+ delete process.env.MY_ENV_VAR;
10
+ });
11
+
12
+ it("returns value when env var is not set", () => {
13
+ const result = EnvironmentLoader({ value: "fallback", name: "NONEXISTENT_VAR" });
14
+ expect(result.get()).toBe("fallback");
15
+ });
16
+
17
+ it("returns null when value is empty", () => {
18
+ const result = EnvironmentLoader({ value: "" });
19
+ expect(result.get()).toBeNull();
20
+ });
21
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @title Environment
3
+ * @hideOption true
4
+ */
5
+ export interface Environment {
6
+ /** @ignore */
7
+ get: () => string | null;
8
+ }
9
+
10
+ export interface Props {
11
+ /**
12
+ * @title Environment Value
13
+ */
14
+ value: string;
15
+ /**
16
+ * @title Environment Name
17
+ * @description Used in dev mode as a environment variable (should not contain spaces or special characters)
18
+ * @pattern ^[a-zA-Z_][a-zA-Z0-9_]*$
19
+ */
20
+ name?: string;
21
+ }
22
+
23
+ const getEnvironment = (props: Props): string | null => {
24
+ const name = props?.name;
25
+ if (name && process.env[name] !== undefined) {
26
+ return process.env[name]!;
27
+ }
28
+ const value = props?.value;
29
+ if (!value) {
30
+ return null;
31
+ }
32
+ return value;
33
+ };
34
+
35
+ /**
36
+ * @title Environment
37
+ */
38
+ export default function EnvironmentLoader(props: Props): Environment {
39
+ const environmentValue = getEnvironment(props);
40
+ return {
41
+ get: (): string | null => {
42
+ return environmentValue;
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import googleFonts from "./googleFonts";
3
+
4
+ describe("googleFonts", () => {
5
+ let fetchSpy: ReturnType<typeof vi.fn>;
6
+
7
+ beforeEach(() => {
8
+ fetchSpy = vi.fn();
9
+ vi.stubGlobal("fetch", fetchSpy);
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ it("returns empty font for no fonts", async () => {
17
+ const result = await googleFonts({ fonts: [] });
18
+ expect(result.family).toBe("");
19
+ expect(result.styleSheet).toBe("");
20
+ expect(fetchSpy).not.toHaveBeenCalled();
21
+ });
22
+
23
+ it("fetches Google Fonts CSS with two User-Agents", async () => {
24
+ fetchSpy.mockResolvedValue({ text: () => Promise.resolve("/* css */") });
25
+
26
+ const result = await googleFonts({
27
+ fonts: [{ family: "Inter", variations: [{ weight: "400" }] }],
28
+ });
29
+
30
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
31
+ expect(result.family).toBe("Inter");
32
+ expect(result.styleSheet).toContain("/* css */");
33
+ });
34
+
35
+ it("handles fetch errors gracefully", async () => {
36
+ fetchSpy.mockRejectedValue(new Error("Network error"));
37
+
38
+ const result = await googleFonts({
39
+ fonts: [{ family: "Roboto", variations: [{ weight: "400" }] }],
40
+ });
41
+
42
+ expect(result.family).toBe("Roboto");
43
+ expect(result.styleSheet).toBe("\n");
44
+ });
45
+
46
+ it("merges duplicate font families", async () => {
47
+ fetchSpy.mockResolvedValue({ text: () => Promise.resolve("") });
48
+
49
+ await googleFonts({
50
+ fonts: [
51
+ { family: "Inter", variations: [{ weight: "400" }] },
52
+ { family: "Inter", variations: [{ weight: "700" }] },
53
+ ],
54
+ });
55
+
56
+ // Should only have one "family" param for "Inter" with merged variations
57
+ const calledUrl = fetchSpy.mock.calls[0][0] as URL;
58
+ const families = calledUrl.searchParams.getAll("family");
59
+ expect(families).toHaveLength(1);
60
+ expect(families[0]).toContain("Inter");
61
+ });
62
+ });