diff --git a/biome.json b/biome.json index dfc0498..0c8f6d5 100644 --- a/biome.json +++ b/biome.json @@ -33,6 +33,13 @@ } }, "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/vtex/client.ts b/vtex/client.ts index 0f2722a..b51b08f 100644 --- a/vtex/client.ts +++ b/vtex/client.ts @@ -242,7 +242,7 @@ export async function vtexCachedFetch( export async function vtexFetchWithCookies(path: string, init?: RequestInit): Promise { // Auto-inject request cookies from RequestContext const existingHeaders = init?.headers as Record | undefined; - if (!existingHeaders?.["cookie"]) { + if (!existingHeaders?.cookie) { const ctx = RequestContext.current; const cookies = ctx?.request.headers.get("cookie"); if (cookies) { diff --git a/vtex/commerceLoaders.ts b/vtex/commerceLoaders.ts index 784242e..a684199 100644 --- a/vtex/commerceLoaders.ts +++ b/vtex/commerceLoaders.ts @@ -92,7 +92,7 @@ export function createVtexCommerceLoaders( static: options?.cacheProfiles?.static ?? "static", }; - const cachedProductList = createCachedLoader( + const _cachedProductList = createCachedLoader( "vtex/productList", vtexProductList, profiles.listing, diff --git a/vtex/manifest.gen.ts b/vtex/manifest.gen.ts index ad42fbf..10bb23d 100644 --- a/vtex/manifest.gen.ts +++ b/vtex/manifest.gen.ts @@ -13,6 +13,7 @@ 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"; @@ -36,6 +37,7 @@ 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..22d74ee --- /dev/null +++ b/website/__tests__/flags.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import Audience from "../flags/audience"; +import Everyone from "../flags/everyone"; +import Flag from "../flags/flag"; +import type { Matcher } from "../types"; +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({} as any)).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..cdd567e --- /dev/null +++ b/website/__tests__/loaders.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import EnvironmentLoader from "../loaders/environment"; +import googleFonts from "../loaders/fonts/googleFonts"; +import localFonts from "../loaders/fonts/local"; +import SecretLoader from "../loaders/secret"; + +// --------------------------------------------------------------------------- +// 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; + }); + + it("reads empty-string env var correctly", () => { + process.env.EMPTY_SECRET = ""; + const result = SecretLoader({ encrypted: "fallback", name: "EMPTY_SECRET" }); + expect(result.get()).toBe(""); + delete process.env.EMPTY_SECRET; + }); +}); + +// --------------------------------------------------------------------------- +// 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..54315ec --- /dev/null +++ b/website/__tests__/matchers.test.ts @@ -0,0 +1,495 @@ +import { describe, expect, it } from "vitest"; +import MatchAlways from "../matchers/always"; +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 MatchNever from "../matchers/never"; +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"; +import type { MatchContext } from "../types"; + +// --------------------------------------------------------------------------- +// 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); + }); + + it("treats weekday 7 as Sunday (alias for 0)", () => { + const now = new Date(); + const isSunday = now.getDay() === 0; + // Weekday 7 is a common cron alias for Sunday + const result = MatchCron({ cron: `* * * * 7` }); + expect(result).toBe(isSunday); + }); +}); + +// --------------------------------------------------------------------------- +// 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); + }); + + it("escapes regex metacharacters in template (dots)", () => { + const ctx = makeCtx({ url: "https://example.com/api.v2/users" }); + expect(MatchPathname({ case: { type: "Template", pathname: "/api.v2/:resource" } }, ctx)).toBe( + true, + ); + // Dot should NOT match arbitrary character + const ctx2 = makeCtx({ url: "https://example.com/apiXv2/users" }); + expect(MatchPathname({ case: { type: "Template", pathname: "/api.v2/:resource" } }, ctx2)).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..7d932c3 --- /dev/null +++ b/website/__tests__/mod.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { getWebsiteConfig } from "../client"; +import { configure } from "../mod"; + +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..54f64b3 --- /dev/null +++ b/website/components/Analytics.tsx @@ -0,0 +1,146 @@ +declare global { + interface Window { + dataLayer: unknown[]; + DECO: { events: { subscribe: (fn: (event: any) => void) => void } }; + } +} + +export const getGTMIdFromSrc = (src: string | undefined) => { + if (!src) return undefined; + try { + return new URL(src).searchParams.get("id") ?? undefined; + } catch { + return undefined; + } +}; + +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 ( + <> +