From 9b8139cc6f91ee1b0e899da152fcc8d6274f7b91 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 13 Apr 2026 15:45:32 -0300 Subject: [PATCH 1/4] feat(website): migrate website app from deco-cx/apps (Fresh/Deno) to TanStack Start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the website app (~34 modules) from the Fresh/Deno ecosystem to TanStack Start/React/Node, following the existing AppModContract pattern. Includes: - App scaffold (mod.ts, client.ts, types.ts, index.ts) - 4 components: Seo, Theme, Analytics, Video (Preact → React 19) - 5 loaders: googleFonts, local fonts, secret, secretString, environment - 16 matchers: always, never, cookie, cron, date, device, environment, host, location, multi, negate, pathname, queryString, random, site, userAgent - 8 flags: flag, everyone, audience, multivariate (image/message/page/section) - 3 sections: Seo, SeoV2, Analytics - 3 utils: html, location, multivariate - 97 new tests covering all modules (222 total tests pass) Co-Authored-By: Claude Opus 4.6 --- biome.json | 2 +- package-lock.json | 4 +- package.json | 14 +- scripts/generate-manifests.ts | 1 + shopify/manifest.gen.ts | 13 +- vtex/manifest.gen.ts | 25 +- website/__tests__/flags.test.ts | 119 ++++++ website/__tests__/loaders.test.ts | 193 ++++++++++ website/__tests__/matchers.test.ts | 452 +++++++++++++++++++++++ website/__tests__/mod.test.ts | 45 +++ website/__tests__/utils.test.ts | 71 ++++ website/client.ts | 20 + website/components/Analytics.tsx | 141 +++++++ website/components/Seo.tsx | 139 +++++++ website/components/Theme.tsx | 47 +++ website/components/Video.tsx | 44 +++ website/flags/audience.ts | 56 +++ website/flags/everyone.ts | 19 + website/flags/flag.ts | 15 + website/flags/multivariate.ts | 1 + website/flags/multivariate/image.ts | 11 + website/flags/multivariate/message.ts | 11 + website/flags/multivariate/page.ts | 16 + website/flags/multivariate/section.ts | 16 + website/index.ts | 22 ++ website/loaders/environment.ts | 45 +++ website/loaders/fonts/googleFonts.ts | 119 ++++++ website/loaders/fonts/local.ts | 85 +++++ website/loaders/secret.ts | 60 +++ website/loaders/secretString.ts | 18 + website/manifest.gen.ts | 31 ++ website/matchers/always.ts | 12 + website/matchers/cookie.ts | 33 ++ website/matchers/cron.ts | 114 ++++++ website/matchers/date.ts | 29 ++ website/matchers/device.ts | 40 ++ website/matchers/environment.ts | 21 ++ website/matchers/host.ts | 25 ++ website/matchers/location.ts | 113 ++++++ website/matchers/multi.ts | 24 ++ website/matchers/negate.ts | 21 ++ website/matchers/never.ts | 12 + website/matchers/pathname.ts | 66 ++++ website/matchers/queryString.ts | 98 +++++ website/matchers/random.ts | 24 ++ website/matchers/site.ts | 21 ++ website/matchers/userAgent.ts | 23 ++ website/mod.ts | 47 +++ website/sections/Analytics/Analytics.tsx | 7 + website/sections/Seo/Seo.tsx | 14 + website/sections/Seo/SeoV2.tsx | 45 +++ website/types.ts | 125 +++++++ website/utils/html.ts | 1 + website/utils/location.ts | 20 + website/utils/multivariate.ts | 20 + 55 files changed, 2787 insertions(+), 23 deletions(-) create mode 100644 website/__tests__/flags.test.ts create mode 100644 website/__tests__/loaders.test.ts create mode 100644 website/__tests__/matchers.test.ts create mode 100644 website/__tests__/mod.test.ts create mode 100644 website/__tests__/utils.test.ts create mode 100644 website/client.ts create mode 100644 website/components/Analytics.tsx create mode 100644 website/components/Seo.tsx create mode 100644 website/components/Theme.tsx create mode 100644 website/components/Video.tsx create mode 100644 website/flags/audience.ts create mode 100644 website/flags/everyone.ts create mode 100644 website/flags/flag.ts create mode 100644 website/flags/multivariate.ts create mode 100644 website/flags/multivariate/image.ts create mode 100644 website/flags/multivariate/message.ts create mode 100644 website/flags/multivariate/page.ts create mode 100644 website/flags/multivariate/section.ts create mode 100644 website/index.ts create mode 100644 website/loaders/environment.ts create mode 100644 website/loaders/fonts/googleFonts.ts create mode 100644 website/loaders/fonts/local.ts create mode 100644 website/loaders/secret.ts create mode 100644 website/loaders/secretString.ts create mode 100644 website/manifest.gen.ts create mode 100644 website/matchers/always.ts create mode 100644 website/matchers/cookie.ts create mode 100644 website/matchers/cron.ts create mode 100644 website/matchers/date.ts create mode 100644 website/matchers/device.ts create mode 100644 website/matchers/environment.ts create mode 100644 website/matchers/host.ts create mode 100644 website/matchers/location.ts create mode 100644 website/matchers/multi.ts create mode 100644 website/matchers/negate.ts create mode 100644 website/matchers/never.ts create mode 100644 website/matchers/pathname.ts create mode 100644 website/matchers/queryString.ts create mode 100644 website/matchers/random.ts create mode 100644 website/matchers/site.ts create mode 100644 website/matchers/userAgent.ts create mode 100644 website/mod.ts create mode 100644 website/sections/Analytics/Analytics.tsx create mode 100644 website/sections/Seo/Seo.tsx create mode 100644 website/sections/Seo/SeoV2.tsx create mode 100644 website/types.ts create mode 100644 website/utils/html.ts create mode 100644 website/utils/location.ts create mode 100644 website/utils/multivariate.ts diff --git a/biome.json b/biome.json index dfc0498..5cd7f32 100644 --- a/biome.json +++ b/biome.json @@ -33,6 +33,6 @@ } }, "files": { - "includes": ["commerce/**", "shopify/**", "vtex/**", "resend/**", "vitest.config.ts"] + "includes": ["commerce/**", "shopify/**", "vtex/**", "resend/**", "website/**", "vitest.config.ts"] } } diff --git a/package-lock.json b/package-lock.json index 21892d5..1775aee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@decocms/apps", - "version": "0.27.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@decocms/apps", - "version": "0.27.0", + "version": "1.2.0", "license": "MIT", "devDependencies": { "@biomejs/biome": "^2.4.7", diff --git a/package.json b/package.json index 80c97e3..b10d843 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,18 @@ "./resend/mod": "./resend/mod.ts", "./resend/client": "./resend/client.ts", "./resend/types": "./resend/types.ts", - "./resend/actions/send": "./resend/actions/send.ts" + "./resend/actions/send": "./resend/actions/send.ts", + "./website": "./website/index.ts", + "./website/mod": "./website/mod.ts", + "./website/client": "./website/client.ts", + "./website/types": "./website/types.ts", + "./website/components/*": "./website/components/*.tsx", + "./website/loaders/*": "./website/loaders/*.ts", + "./website/loaders/fonts/*": "./website/loaders/fonts/*.ts", + "./website/matchers/*": "./website/matchers/*.ts", + "./website/flags/*": "./website/flags/*.ts", + "./website/flags/multivariate/*": "./website/flags/multivariate/*.ts", + "./website/utils/*": "./website/utils/*.ts" }, "scripts": { "generate:manifests": "tsx scripts/generate-manifests.ts", @@ -80,6 +91,7 @@ "shopify/", "vtex/", "resend/", + "website/", "!**/__tests__/", "!scripts/" ], diff --git a/scripts/generate-manifests.ts b/scripts/generate-manifests.ts index 8887a87..156079b 100644 --- a/scripts/generate-manifests.ts +++ b/scripts/generate-manifests.ts @@ -21,6 +21,7 @@ const APPS: AppConfig[] = [ { name: "vtex", dir: "vtex" }, { name: "shopify", dir: "shopify" }, { name: "resend", dir: "resend" }, + { name: "website", dir: "website" }, ]; const CATEGORIES = ["loaders", "actions", "sections"] as const; diff --git a/shopify/manifest.gen.ts b/shopify/manifest.gen.ts index 8771406..c46c749 100644 --- a/shopify/manifest.gen.ts +++ b/shopify/manifest.gen.ts @@ -1,18 +1,17 @@ // AUTO-GENERATED by scripts/generate-manifests.ts — DO NOT EDIT // This file is checked into source control and updated via: npm run generate:manifests - -import * as actions_cart_addItems from "./actions/cart/addItems"; -import * as actions_cart_updateCoupons from "./actions/cart/updateCoupons"; -import * as actions_cart_updateItems from "./actions/cart/updateItems"; -import * as actions_user_signIn from "./actions/user/signIn"; -import * as actions_user_signUp from "./actions/user/signUp"; -import * as loaders_cart from "./loaders/cart"; import * as loaders_ProductDetailsPage from "./loaders/ProductDetailsPage"; import * as loaders_ProductList from "./loaders/ProductList"; import * as loaders_ProductListingPage from "./loaders/ProductListingPage"; import * as loaders_RelatedProducts from "./loaders/RelatedProducts"; +import * as loaders_cart from "./loaders/cart"; import * as loaders_shop from "./loaders/shop"; import * as loaders_user from "./loaders/user"; +import * as actions_cart_addItems from "./actions/cart/addItems"; +import * as actions_cart_updateCoupons from "./actions/cart/updateCoupons"; +import * as actions_cart_updateItems from "./actions/cart/updateItems"; +import * as actions_user_signIn from "./actions/user/signIn"; +import * as actions_user_signUp from "./actions/user/signUp"; const manifest = { name: "shopify", diff --git a/vtex/manifest.gen.ts b/vtex/manifest.gen.ts index ad42fbf..607ce6a 100644 --- a/vtex/manifest.gen.ts +++ b/vtex/manifest.gen.ts @@ -1,18 +1,7 @@ // AUTO-GENERATED by scripts/generate-manifests.ts — DO NOT EDIT // This file is checked into source control and updated via: npm run generate:manifests - -import * as actions_address from "./actions/address"; -import * as actions_auth from "./actions/auth"; -import * as actions_checkout from "./actions/checkout"; -import * as actions_masterData from "./actions/masterData"; -import * as actions_misc from "./actions/misc"; -import * as actions_newsletter from "./actions/newsletter"; -import * as actions_orders from "./actions/orders"; -import * as actions_profile from "./actions/profile"; -import * as actions_session from "./actions/session"; -import * as actions_trigger from "./actions/trigger"; -import * as actions_wishlist from "./actions/wishlist"; import * as loaders_address from "./loaders/address"; +import * as loaders_autocomplete from "./loaders/autocomplete"; import * as loaders_brands from "./loaders/brands"; import * as loaders_cart from "./loaders/cart"; import * as loaders_catalog from "./loaders/catalog"; @@ -31,11 +20,23 @@ import * as loaders_user from "./loaders/user"; import * as loaders_wishlist from "./loaders/wishlist"; import * as loaders_wishlistProducts from "./loaders/wishlistProducts"; import * as loaders_workflow from "./loaders/workflow"; +import * as actions_address from "./actions/address"; +import * as actions_auth from "./actions/auth"; +import * as actions_checkout from "./actions/checkout"; +import * as actions_masterData from "./actions/masterData"; +import * as actions_misc from "./actions/misc"; +import * as actions_newsletter from "./actions/newsletter"; +import * as actions_orders from "./actions/orders"; +import * as actions_profile from "./actions/profile"; +import * as actions_session from "./actions/session"; +import * as actions_trigger from "./actions/trigger"; +import * as actions_wishlist from "./actions/wishlist"; const manifest = { name: "vtex", loaders: { "vtex/loaders/address": loaders_address, + "vtex/loaders/autocomplete": loaders_autocomplete, "vtex/loaders/brands": loaders_brands, "vtex/loaders/cart": loaders_cart, "vtex/loaders/catalog": loaders_catalog, diff --git a/website/__tests__/flags.test.ts b/website/__tests__/flags.test.ts new file mode 100644 index 0000000..bceee2d --- /dev/null +++ b/website/__tests__/flags.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { FlagObj, Matcher } from "../types"; +import Flag from "../flags/flag"; +import Audience from "../flags/audience"; +import Everyone from "../flags/everyone"; +import multivariate from "../utils/multivariate"; + +// --------------------------------------------------------------------------- +// flag.ts +// --------------------------------------------------------------------------- + +describe("Flag", () => { + it("returns a FlagObj with the same values", () => { + const matcher: Matcher = () => true; + const result = Flag({ + matcher, + true: "variant-a", + false: "variant-b", + name: "test-flag", + }); + + expect(result.matcher).toBe(matcher); + expect(result.true).toBe("variant-a"); + expect(result.false).toBe("variant-b"); + expect(result.name).toBe("test-flag"); + }); + + it("preserves complex true/false values", () => { + const routes = [{ pathTemplate: "/*", handler: { value: {} } }]; + const result = Flag({ + matcher: () => false, + true: routes, + false: [], + name: "routes-flag", + }); + + expect(result.true).toBe(routes); + expect(result.false).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// audience.ts +// --------------------------------------------------------------------------- + +describe("Audience", () => { + it("returns FlagObj with routes as true branch and empty as false", () => { + const matcher: Matcher = () => true; + const routes = [{ pathTemplate: "/products/*", handler: { value: {} } }]; + + const result = Audience({ matcher, name: "vip-users", routes }); + + expect(result.name).toBe("vip-users"); + expect(result.true).toEqual(routes); + expect(result.false).toEqual([]); + }); + + it("defaults routes to empty array when not provided", () => { + const result = Audience({ matcher: () => false, name: "empty" }); + + expect(result.true).toEqual([]); + expect(result.false).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// everyone.ts +// --------------------------------------------------------------------------- + +describe("Everyone", () => { + it("creates a flag named Everyone that always matches", () => { + const routes = [{ pathTemplate: "/*", handler: { value: {} } }]; + const result = Everyone({ routes }); + + expect(result.name).toBe("Everyone"); + expect(result.true).toEqual(routes); + expect(result.false).toEqual([]); + // The matcher should be MatchAlways which returns true + expect(result.matcher()).toBe(true); + }); + + it("works with no routes", () => { + const result = Everyone({}); + + expect(result.name).toBe("Everyone"); + expect(result.true).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// multivariate +// --------------------------------------------------------------------------- + +describe("multivariate", () => { + it("returns the variants as-is", () => { + const variants = [ + { value: "A", weight: 0.5 }, + { value: "B", weight: 0.5 }, + ]; + + const result = multivariate({ variants }); + + expect(result.variants).toBe(variants); + expect(result.variants).toHaveLength(2); + }); + + it("supports variants with matchers", () => { + const matcher: Matcher = () => true; + const variants = [ + { value: "control", matcher }, + { value: "default" }, + ]; + + const result = multivariate({ variants }); + + expect(result.variants[0].matcher).toBe(matcher); + expect(result.variants[1].matcher).toBeUndefined(); + }); +}); diff --git a/website/__tests__/loaders.test.ts b/website/__tests__/loaders.test.ts new file mode 100644 index 0000000..c0e94cc --- /dev/null +++ b/website/__tests__/loaders.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import localFonts from "../loaders/fonts/local"; +import googleFonts from "../loaders/fonts/googleFonts"; +import SecretLoader from "../loaders/secret"; +import EnvironmentLoader from "../loaders/environment"; + +// --------------------------------------------------------------------------- +// loaders/fonts/local.ts +// --------------------------------------------------------------------------- + +describe("localFonts", () => { + it("returns empty family and stylesheet for no fonts", () => { + const result = localFonts({ fonts: [] }); + expect(result.family).toBe(""); + expect(result.styleSheet).toBe(""); + }); + + it("generates @font-face for a single font", () => { + const result = localFonts({ + fonts: [ + { + family: "CustomFont", + variations: [{ weight: "400", italic: false, src: "https://cdn.example.com/font.woff2" }], + }, + ], + }); + + expect(result.family).toBe("CustomFont"); + expect(result.styleSheet).toContain("@font-face"); + expect(result.styleSheet).toContain("font-family: 'CustomFont'"); + expect(result.styleSheet).toContain("font-weight: 400"); + expect(result.styleSheet).toContain("font-style: normal"); + expect(result.styleSheet).toContain("format('woff2')"); + }); + + it("generates italic font-face", () => { + const result = localFonts({ + fonts: [ + { + family: "CustomFont", + variations: [{ weight: "700", italic: true, src: "https://cdn.example.com/font-bold-italic.woff2" }], + }, + ], + }); + + expect(result.styleSheet).toContain("font-style: italic"); + expect(result.styleSheet).toContain("font-weight: 700"); + }); + + it("merges duplicate font families", () => { + const result = localFonts({ + fonts: [ + { + family: "Inter", + variations: [{ weight: "400", src: "https://cdn.example.com/inter-400.woff2" }], + }, + { + family: "Inter", + variations: [{ weight: "700", src: "https://cdn.example.com/inter-700.woff2" }], + }, + ], + }); + + expect(result.family).toBe("Inter"); + expect(result.styleSheet).toContain("font-weight: 400"); + expect(result.styleSheet).toContain("font-weight: 700"); + }); + + it("detects font format from extension", () => { + const ttfResult = localFonts({ + fonts: [{ family: "F", variations: [{ weight: "400", src: "https://x.com/f.ttf" }] }], + }); + expect(ttfResult.styleSheet).toContain("format('truetype')"); + + const woffResult = localFonts({ + fonts: [{ family: "F", variations: [{ weight: "400", src: "https://x.com/f.woff" }] }], + }); + expect(woffResult.styleSheet).toContain("format('woff')"); + }); +}); + +// --------------------------------------------------------------------------- +// loaders/fonts/googleFonts.ts +// --------------------------------------------------------------------------- + +describe("googleFonts", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns empty font for no fonts", async () => { + const result = await googleFonts({ fonts: [] }); + expect(result.family).toBe(""); + expect(result.styleSheet).toBe(""); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("fetches Google Fonts CSS with two User-Agents", async () => { + fetchSpy.mockResolvedValue({ text: () => Promise.resolve("/* css */") }); + + const result = await googleFonts({ + fonts: [{ family: "Inter", variations: [{ weight: "400" }] }], + }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result.family).toBe("Inter"); + expect(result.styleSheet).toContain("/* css */"); + }); + + it("handles fetch errors gracefully", async () => { + fetchSpy.mockRejectedValue(new Error("Network error")); + + const result = await googleFonts({ + fonts: [{ family: "Roboto", variations: [{ weight: "400" }] }], + }); + + expect(result.family).toBe("Roboto"); + expect(result.styleSheet).toBe("\n"); + }); + + it("merges duplicate font families", async () => { + fetchSpy.mockResolvedValue({ text: () => Promise.resolve("") }); + + await googleFonts({ + fonts: [ + { family: "Inter", variations: [{ weight: "400" }] }, + { family: "Inter", variations: [{ weight: "700" }] }, + ], + }); + + // Should only have one "family" param for "Inter" with merged variations + const calledUrl = fetchSpy.mock.calls[0][0] as URL; + const families = calledUrl.searchParams.getAll("family"); + expect(families).toHaveLength(1); + expect(families[0]).toContain("Inter"); + }); +}); + +// --------------------------------------------------------------------------- +// loaders/secret.ts +// --------------------------------------------------------------------------- + +describe("SecretLoader", () => { + it("reads from process.env when name is set", () => { + process.env.MY_SECRET = "super-secret"; + const result = SecretLoader({ encrypted: "xxx", name: "MY_SECRET" }); + expect(result.get()).toBe("super-secret"); + delete process.env.MY_SECRET; + }); + + it("returns null when encrypted is empty", () => { + const result = SecretLoader({ encrypted: "" }); + expect(result.get()).toBeNull(); + }); + + it("returns encrypted value as fallback in dev", () => { + const original = process.env.NODE_ENV; + process.env.NODE_ENV = "test"; + const result = SecretLoader({ encrypted: "encrypted-value", name: "NONEXISTENT" }); + expect(result.get()).toBe("encrypted-value"); + process.env.NODE_ENV = original; + }); +}); + +// --------------------------------------------------------------------------- +// loaders/environment.ts +// --------------------------------------------------------------------------- + +describe("EnvironmentLoader", () => { + it("reads from process.env when name is set", () => { + process.env.MY_ENV_VAR = "hello"; + const result = EnvironmentLoader({ value: "fallback", name: "MY_ENV_VAR" }); + expect(result.get()).toBe("hello"); + delete process.env.MY_ENV_VAR; + }); + + it("returns value when env var is not set", () => { + const result = EnvironmentLoader({ value: "fallback", name: "NONEXISTENT_VAR" }); + expect(result.get()).toBe("fallback"); + }); + + it("returns null when value is empty", () => { + const result = EnvironmentLoader({ value: "" }); + expect(result.get()).toBeNull(); + }); +}); diff --git a/website/__tests__/matchers.test.ts b/website/__tests__/matchers.test.ts new file mode 100644 index 0000000..94e18d5 --- /dev/null +++ b/website/__tests__/matchers.test.ts @@ -0,0 +1,452 @@ +import { describe, expect, it } from "vitest"; +import type { MatchContext } from "../types"; +import MatchAlways from "../matchers/always"; +import MatchNever from "../matchers/never"; +import MatchCookie from "../matchers/cookie"; +import MatchCron from "../matchers/cron"; +import MatchDate from "../matchers/date"; +import MatchDevice from "../matchers/device"; +import MatchEnvironment from "../matchers/environment"; +import MatchHost from "../matchers/host"; +import MatchLocation from "../matchers/location"; +import MatchMulti from "../matchers/multi"; +import NegateMatcher from "../matchers/negate"; +import MatchPathname from "../matchers/pathname"; +import MatchQueryString from "../matchers/queryString"; +import MatchRandom from "../matchers/random"; +import MatchSite from "../matchers/site"; +import MatchUserAgent from "../matchers/userAgent"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeCtx = (overrides: Partial & { url?: string; headers?: Record } = {}): MatchContext => { + const { url = "https://example.com/", headers = {}, ...rest } = overrides; + return { + request: new Request(url, { headers }), + device: "desktop", + siteId: 1, + ...rest, + }; +}; + +// --------------------------------------------------------------------------- +// always / never +// --------------------------------------------------------------------------- + +describe("MatchAlways", () => { + it("always returns true", () => { + expect(MatchAlways()).toBe(true); + }); +}); + +describe("MatchNever", () => { + it("always returns false", () => { + expect(MatchNever()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// cookie +// --------------------------------------------------------------------------- + +describe("MatchCookie", () => { + it("matches when cookie value equals", () => { + const ctx = makeCtx({ headers: { cookie: "theme=dark; lang=en" } }); + expect(MatchCookie({ name: "theme", value: "dark" }, ctx)).toBe(true); + }); + + it("does not match when cookie value differs", () => { + const ctx = makeCtx({ headers: { cookie: "theme=light" } }); + expect(MatchCookie({ name: "theme", value: "dark" }, ctx)).toBe(false); + }); + + it("does not match when cookie is missing", () => { + const ctx = makeCtx({}); + expect(MatchCookie({ name: "theme", value: "dark" }, ctx)).toBe(false); + }); + + it("handles cookie values with = sign", () => { + const ctx = makeCtx({ headers: { cookie: "token=abc=123" } }); + expect(MatchCookie({ name: "token", value: "abc=123" }, ctx)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// cron +// --------------------------------------------------------------------------- + +describe("MatchCron", () => { + it("returns false for empty cron", () => { + expect(MatchCron({ cron: "" })).toBe(false); + }); + + it("matches wildcard cron (every minute)", () => { + expect(MatchCron({ cron: "* * * * *" })).toBe(true); + }); + + it("does not match impossible cron (Feb 30)", () => { + // minute 99 doesn't exist + expect(MatchCron({ cron: "99 99 30 2 *" })).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// date +// --------------------------------------------------------------------------- + +describe("MatchDate", () => { + it("matches when now is within range", () => { + const past = new Date(Date.now() - 86400000).toISOString(); + const future = new Date(Date.now() + 86400000).toISOString(); + expect(MatchDate({ start: past, end: future })).toBe(true); + }); + + it("does not match when now is before start", () => { + const future = new Date(Date.now() + 86400000).toISOString(); + expect(MatchDate({ start: future })).toBe(false); + }); + + it("does not match when now is after end", () => { + const past = new Date(Date.now() - 86400000).toISOString(); + expect(MatchDate({ end: past })).toBe(false); + }); + + it("matches when no bounds specified", () => { + expect(MatchDate({})).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// device +// --------------------------------------------------------------------------- + +describe("MatchDevice", () => { + it("matches desktop", () => { + expect(MatchDevice({ desktop: true }, makeCtx({ device: "desktop" }))).toBe(true); + }); + + it("matches mobile", () => { + expect(MatchDevice({ mobile: true }, makeCtx({ device: "mobile" }))).toBe(true); + }); + + it("does not match wrong device", () => { + expect(MatchDevice({ mobile: true }, makeCtx({ device: "desktop" }))).toBe(false); + }); + + it("matches multiple devices", () => { + expect(MatchDevice({ mobile: true, tablet: true }, makeCtx({ device: "tablet" }))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// environment +// --------------------------------------------------------------------------- + +describe("MatchEnvironment", () => { + it("matches production when NODE_ENV is production", () => { + const original = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + expect(MatchEnvironment({ environment: "production" })).toBe(true); + expect(MatchEnvironment({ environment: "development" })).toBe(false); + process.env.NODE_ENV = original; + }); + + it("matches development when NODE_ENV is not production", () => { + const original = process.env.NODE_ENV; + process.env.NODE_ENV = "test"; + expect(MatchEnvironment({ environment: "development" })).toBe(true); + expect(MatchEnvironment({ environment: "production" })).toBe(false); + process.env.NODE_ENV = original; + }); +}); + +// --------------------------------------------------------------------------- +// host +// --------------------------------------------------------------------------- + +describe("MatchHost", () => { + it("matches host by includes", () => { + const ctx = makeCtx({ headers: { host: "www.example.com" } }); + expect(MatchHost({ includes: "example" }, ctx)).toBe(true); + }); + + it("does not match when host does not include", () => { + const ctx = makeCtx({ headers: { host: "www.other.com" } }); + expect(MatchHost({ includes: "example" }, ctx)).toBe(false); + }); + + it("matches host by regex", () => { + const ctx = makeCtx({ headers: { host: "store.example.com" } }); + expect(MatchHost({ match: "^store\\." }, ctx)).toBe(true); + }); + + it("does not match when regex fails", () => { + const ctx = makeCtx({ headers: { host: "www.example.com" } }); + expect(MatchHost({ match: "^store\\." }, ctx)).toBe(false); + }); + + it("matches when both includes and match pass", () => { + const ctx = makeCtx({ headers: { host: "store.example.com" } }); + expect(MatchHost({ includes: "example", match: "^store" }, ctx)).toBe(true); + }); + + it("returns true when no conditions specified", () => { + const ctx = makeCtx({ headers: { host: "anything.com" } }); + expect(MatchHost({}, ctx)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// location +// --------------------------------------------------------------------------- + +describe("MatchLocation", () => { + it("matches by country", () => { + const ctx = makeCtx({ headers: { "cf-ipcountry": "BR" } }); + expect(MatchLocation({ includeLocations: [{ country: "BR" }] }, ctx)).toBe(true); + }); + + it("does not match wrong country", () => { + const ctx = makeCtx({ headers: { "cf-ipcountry": "US" } }); + expect(MatchLocation({ includeLocations: [{ country: "BR" }] }, ctx)).toBe(false); + }); + + it("excludes matching location", () => { + const ctx = makeCtx({ headers: { "cf-ipcountry": "BR" } }); + expect(MatchLocation({ excludeLocations: [{ country: "BR" }] }, ctx)).toBe(false); + }); + + it("matches by city", () => { + const ctx = makeCtx({ headers: { "cf-ipcity": "Sao Paulo", "cf-ipcountry": "BR" } }); + expect(MatchLocation({ includeLocations: [{ city: "Sao Paulo" }] }, ctx)).toBe(true); + }); + + it("returns true when includeLocations is empty", () => { + const ctx = makeCtx({}); + expect(MatchLocation({ includeLocations: [] }, ctx)).toBe(true); + }); + + it("returns true when no locations specified", () => { + const ctx = makeCtx({}); + expect(MatchLocation({}, ctx)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// multi +// --------------------------------------------------------------------------- + +describe("MatchMulti", () => { + const trueM: MatchContext["request"] extends any ? () => boolean : never = () => true; + const falseM = () => false; + + it("OR: true if any matcher matches", () => { + const matcher = MatchMulti({ op: "or", matchers: [falseM, trueM] }); + expect(matcher(makeCtx())).toBe(true); + }); + + it("OR: false if no matcher matches", () => { + const matcher = MatchMulti({ op: "or", matchers: [falseM, falseM] }); + expect(matcher(makeCtx())).toBe(false); + }); + + it("AND: true if all matchers match", () => { + const matcher = MatchMulti({ op: "and", matchers: [trueM, trueM] }); + expect(matcher(makeCtx())).toBe(true); + }); + + it("AND: false if any matcher fails", () => { + const matcher = MatchMulti({ op: "and", matchers: [trueM, falseM] }); + expect(matcher(makeCtx())).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// negate +// --------------------------------------------------------------------------- + +describe("NegateMatcher", () => { + it("negates a true matcher", () => { + const matcher = NegateMatcher({ matcher: () => true }); + expect(matcher(makeCtx())).toBe(false); + }); + + it("negates a false matcher", () => { + const matcher = NegateMatcher({ matcher: () => false }); + expect(matcher(makeCtx())).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// pathname +// --------------------------------------------------------------------------- + +describe("MatchPathname", () => { + it("matches exact pathname (Equals)", () => { + const ctx = makeCtx({ url: "https://example.com/about" }); + expect(MatchPathname({ case: { type: "Equals", pathname: "/about" } }, ctx)).toBe(true); + }); + + it("does not match different pathname (Equals)", () => { + const ctx = makeCtx({ url: "https://example.com/contact" }); + expect(MatchPathname({ case: { type: "Equals", pathname: "/about" } }, ctx)).toBe(false); + }); + + it("matches substring (Includes)", () => { + const ctx = makeCtx({ url: "https://example.com/products/shoes" }); + expect(MatchPathname({ case: { type: "Includes", pathname: "/products" } }, ctx)).toBe(true); + }); + + it("matches template pattern (Template)", () => { + const ctx = makeCtx({ url: "https://example.com/product/my-shoe/p" }); + expect(MatchPathname({ case: { type: "Template", pathname: "/product/:slug/p" } }, ctx)).toBe(true); + }); + + it("does not match template for wrong structure", () => { + const ctx = makeCtx({ url: "https://example.com/product/my-shoe/extra/p" }); + expect(MatchPathname({ case: { type: "Template", pathname: "/product/:slug/p" } }, ctx)).toBe(false); + }); + + it("negates the match when negate is true", () => { + const ctx = makeCtx({ url: "https://example.com/about" }); + expect(MatchPathname({ case: { type: "Equals", pathname: "/about", negate: true } }, ctx)).toBe(false); + }); + + it("returns false when pathname is empty", () => { + const ctx = makeCtx({ url: "https://example.com/about" }); + expect(MatchPathname({ case: { type: "Equals" } }, ctx)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// queryString +// --------------------------------------------------------------------------- + +describe("MatchQueryString", () => { + it("matches Equals condition", () => { + const ctx = makeCtx({ url: "https://example.com/?color=red" }); + expect( + MatchQueryString({ conditions: [{ param: "color", case: { type: "Equals", value: "red" } }] }, ctx), + ).toBe(true); + }); + + it("does not match Equals with different value", () => { + const ctx = makeCtx({ url: "https://example.com/?color=blue" }); + expect( + MatchQueryString({ conditions: [{ param: "color", case: { type: "Equals", value: "red" } }] }, ctx), + ).toBe(false); + }); + + it("matches Exists condition", () => { + const ctx = makeCtx({ url: "https://example.com/?color=red" }); + expect( + MatchQueryString({ conditions: [{ param: "color", case: { type: "Exists" } }] }, ctx), + ).toBe(true); + }); + + it("fails Exists when param is missing", () => { + const ctx = makeCtx({ url: "https://example.com/" }); + expect( + MatchQueryString({ conditions: [{ param: "color", case: { type: "Exists" } }] }, ctx), + ).toBe(false); + }); + + it("matches Includes condition", () => { + const ctx = makeCtx({ url: "https://example.com/?name=typescript" }); + expect( + MatchQueryString({ conditions: [{ param: "name", case: { type: "Includes", value: "script" } }] }, ctx), + ).toBe(true); + }); + + it("all conditions must match (AND)", () => { + const ctx = makeCtx({ url: "https://example.com/?a=1&b=2" }); + expect( + MatchQueryString({ + conditions: [ + { param: "a", case: { type: "Equals", value: "1" } }, + { param: "b", case: { type: "Equals", value: "2" } }, + ], + }, ctx), + ).toBe(true); + + expect( + MatchQueryString({ + conditions: [ + { param: "a", case: { type: "Equals", value: "1" } }, + { param: "b", case: { type: "Equals", value: "3" } }, + ], + }, ctx), + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// random +// --------------------------------------------------------------------------- + +describe("MatchRandom", () => { + it("always matches with traffic = 1", () => { + // traffic = 1 means 100% probability + let allTrue = true; + for (let i = 0; i < 100; i++) { + if (!MatchRandom({ traffic: 1 })) { + allTrue = false; + break; + } + } + expect(allTrue).toBe(true); + }); + + it("never matches with traffic = 0", () => { + let anyTrue = false; + for (let i = 0; i < 100; i++) { + if (MatchRandom({ traffic: 0 })) { + anyTrue = true; + break; + } + } + expect(anyTrue).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// site +// --------------------------------------------------------------------------- + +describe("MatchSite", () => { + it("matches when siteId equals", () => { + expect(MatchSite({ siteId: 42 }, makeCtx({ siteId: 42 }))).toBe(true); + }); + + it("does not match when siteId differs", () => { + expect(MatchSite({ siteId: 42 }, makeCtx({ siteId: 99 }))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// userAgent +// --------------------------------------------------------------------------- + +describe("MatchUserAgent", () => { + it("matches by includes", () => { + const ctx = makeCtx({ headers: { "user-agent": "Mozilla/5.0 Chrome/120" } }); + expect(MatchUserAgent({ includes: "Chrome" }, ctx)).toBe(true); + }); + + it("does not match when UA does not include", () => { + const ctx = makeCtx({ headers: { "user-agent": "Mozilla/5.0 Firefox/120" } }); + expect(MatchUserAgent({ includes: "Chrome" }, ctx)).toBe(false); + }); + + it("matches by regex", () => { + const ctx = makeCtx({ headers: { "user-agent": "Googlebot/2.1" } }); + expect(MatchUserAgent({ match: "Googlebot" }, ctx)).toBe(true); + }); + + it("returns true when no conditions specified", () => { + const ctx = makeCtx({ headers: { "user-agent": "anything" } }); + expect(MatchUserAgent({}, ctx)).toBe(true); + }); +}); diff --git a/website/__tests__/mod.test.ts b/website/__tests__/mod.test.ts new file mode 100644 index 0000000..cbf0b7d --- /dev/null +++ b/website/__tests__/mod.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { configure } from "../mod"; +import { getWebsiteConfig } from "../client"; + +describe("configure", () => { + const mockResolveSecret = async () => null; + + it("returns an AppDefinition with name website", async () => { + const result = await configure({}, mockResolveSecret); + + expect(result).not.toBeNull(); + expect(result.name).toBe("website"); + expect(result.manifest).toBeDefined(); + expect(result.state.config).toBeDefined(); + }); + + it("passes SEO config from block data", async () => { + const seo = { title: "My Site", description: "A great site" }; + const result = await configure({ seo }, mockResolveSecret); + + expect(result.state.config.seo).toEqual(seo); + }); + + it("configures the global singleton", async () => { + const seo = { title: "Singleton Test" }; + await configure({ seo }, mockResolveSecret); + + const config = getWebsiteConfig(); + expect(config.seo).toEqual(seo); + }); + + it("works with null/undefined block", async () => { + const result = await configure(null, mockResolveSecret); + + expect(result.name).toBe("website"); + expect(result.state.config.seo).toBeUndefined(); + }); + + it("manifest has loaders and sections", async () => { + const result = await configure({}, mockResolveSecret); + + expect(Object.keys(result.manifest.loaders).length).toBeGreaterThan(0); + expect(Object.keys(result.manifest.sections ?? {}).length).toBeGreaterThan(0); + }); +}); diff --git a/website/__tests__/utils.test.ts b/website/__tests__/utils.test.ts new file mode 100644 index 0000000..259f505 --- /dev/null +++ b/website/__tests__/utils.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { stripHTML } from "../utils/html"; +import { haversine, toRadians } from "../utils/location"; + +// --------------------------------------------------------------------------- +// html.ts +// --------------------------------------------------------------------------- + +describe("stripHTML", () => { + it("strips HTML tags from string", () => { + expect(stripHTML("

Hello World

")).toBe("Hello World"); + }); + + it("returns plain text unchanged", () => { + expect(stripHTML("no tags here")).toBe("no tags here"); + }); + + it("handles empty string", () => { + expect(stripHTML("")).toBe(""); + }); + + it("strips self-closing tags", () => { + expect(stripHTML("Hello
World")).toBe("HelloWorld"); + }); + + it("strips nested tags", () => { + expect(stripHTML("

text

")).toBe("text"); + }); +}); + +// --------------------------------------------------------------------------- +// location.ts +// --------------------------------------------------------------------------- + +describe("toRadians", () => { + it("converts 0 degrees to 0 radians", () => { + expect(toRadians(0)).toBe(0); + }); + + it("converts 180 degrees to PI radians", () => { + expect(toRadians(180)).toBeCloseTo(Math.PI); + }); + + it("converts 90 degrees to PI/2 radians", () => { + expect(toRadians(90)).toBeCloseTo(Math.PI / 2); + }); +}); + +describe("haversine", () => { + it("returns 0 for same coordinates", () => { + expect(haversine("-23.5505,-46.6333", "-23.5505,-46.6333")).toBeCloseTo(0, 0); + }); + + it("calculates distance between São Paulo and Rio (~360km)", () => { + const sp = "-23.5505,-46.6333"; + const rj = "-22.9068,-43.1729"; + const distance = haversine(sp, rj); + // ~360km + expect(distance).toBeGreaterThan(350000); + expect(distance).toBeLessThan(380000); + }); + + it("calculates distance between New York and London (~5570km)", () => { + const ny = "40.7128,-74.0060"; + const london = "51.5074,-0.1278"; + const distance = haversine(ny, london); + // ~5570km + expect(distance).toBeGreaterThan(5500000); + expect(distance).toBeLessThan(5700000); + }); +}); diff --git a/website/client.ts b/website/client.ts new file mode 100644 index 0000000..312fa0e --- /dev/null +++ b/website/client.ts @@ -0,0 +1,20 @@ +/** + * Website app singleton configuration. + * + * Follows the same pattern as vtex/client.ts and resend/client.ts. + */ + +import type { WebsiteConfig } from "./types"; + +let _config: WebsiteConfig | null = null; + +export function configureWebsite(config: WebsiteConfig): void { + _config = config; +} + +export function getWebsiteConfig(): WebsiteConfig { + if (!_config) { + throw new Error("Website app not configured. Call configureWebsite() first."); + } + return _config; +} diff --git a/website/components/Analytics.tsx b/website/components/Analytics.tsx new file mode 100644 index 0000000..1e6b33a --- /dev/null +++ b/website/components/Analytics.tsx @@ -0,0 +1,141 @@ +declare global { + interface Window { + dataLayer: unknown[]; + DECO: { events: { subscribe: (fn: (event: any) => void) => void } }; + } +} + +export const getGTMIdFromSrc = (src: string | undefined) => { + const trackingId = src ? new URL(src).searchParams.get("id") : undefined; + return trackingId; +}; + +interface TagManagerProps { + trackingId: string; + src?: string; +} + +export function GoogleTagManager(props: TagManagerProps) { + const _isOnPremises = !!props.src; + const hasTrackingId = "trackingId" in props; + const id = _isOnPremises ? props.src : props.trackingId; + const hostname = _isOnPremises ? props.src : "https://www.googletagmanager.com"; + const src = new URL(`/gtm.js?id=${hasTrackingId ? props.trackingId : ""}`, hostname); + const noscript = new URL(`/ns.html?id=${hasTrackingId ? props.trackingId : ""}`, hostname); + + return ( + <> +