From a075046bc213ed80be4f624be07d17d5b7bf28d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:33:15 +0000 Subject: [PATCH] chore: sync storefront from monorepo Source: coded-letter/coded-letter-monorepo@f8ac2a236416b16346b5cc77699d2776ee504545 --- apps/storefront/.env.example | 19 +----- .../scripts/bootstrap-performance.test.mjs | 2 +- apps/storefront/scripts/netlify-config.mjs | 67 +++++++++++++++++++ .../scripts/netlify-config.test.mjs | 40 +++++++++++ apps/storefront/scripts/prerender.mjs | 27 +++++--- apps/storefront/scripts/route-paths.mjs | 2 +- apps/storefront/scripts/seo-metadata.test.mjs | 4 ++ .../scripts/shortcode-rendering.test.mjs | 2 +- apps/storefront/scripts/sitemap-url.mjs | 12 ++++ apps/storefront/scripts/sitemap-url.test.mjs | 17 +++++ .../src/components/wordpressShortcodes.tsx | 2 +- .../src/lib/asyncSearchCache.test.ts | 48 +++++++++++++ apps/storefront/src/lib/asyncSearchCache.ts | 45 +++++++++++++ apps/storefront/src/lib/cmsBehaviors.test.ts | 32 +++++++++ apps/storefront/src/lib/cmsBehaviors.ts | 6 +- apps/storefront/src/lib/navigation.ts | 6 +- apps/storefront/src/lib/payments.ts | 2 +- .../src/lib/profileHeaderSharing.test.ts | 6 +- apps/storefront/src/lib/push.ts | 4 +- apps/storefront/src/lib/search.ts | 30 ++++++++- apps/storefront/src/lib/shortcodeCta.ts | 6 +- .../src/lib/shortcodeMarkup.test.ts | 2 +- apps/storefront/src/lib/stripe.ts | 5 +- apps/storefront/src/main.tsx | 2 +- apps/storefront/src/pages/AuthMockupPage.tsx | 2 +- .../src/pages/CheckoutMockupPage.tsx | 7 +- .../src/pages/CommunityPostMockupPage.tsx | 6 +- .../src/pages/LayoutStudioMockupPage.tsx | 2 +- .../src/pages/PostTaxonomyArchivePage.tsx | 5 +- .../src/pages/ProductMockupPage.tsx | 5 +- .../src/pages/ShortcodeLibraryMockupPage.tsx | 4 +- apps/storefront/src/pages/shared.tsx | 18 ++--- packages/ui/src/catalog/ProductCard.tsx | 8 ++- .../ui/src/catalog/ProductQuickViewModal.tsx | 11 +-- .../ui/src/catalog/productCardGallery.test.ts | 3 +- packages/ui/src/layout/CartDrawer.tsx | 2 +- packages/ui/src/layout/CartDropdown.tsx | 2 +- .../ui/src/layout/CookieConsentBanner.tsx | 2 +- packages/ui/src/layout/FooterMockup.tsx | 6 +- packages/ui/src/layout/HeaderMockup.test.ts | 15 ++++- packages/ui/src/layout/HeaderMockup.tsx | 22 ++++-- .../ui/src/layout/NewsletterSignupPopup.tsx | 4 +- .../src/layout/StorefrontChromeMockup.test.ts | 11 +++ .../ui/src/layout/StorefrontChromeMockup.tsx | 15 ++++- packages/ui/src/locale/en.json | 6 +- packages/ui/src/locale/ja.json | 6 +- packages/ui/src/locale/pl.json | 6 +- packages/ui/src/seo/Seo.tsx | 2 +- packages/ui/src/state/CartContext.tsx | 11 ++- 49 files changed, 466 insertions(+), 103 deletions(-) create mode 100644 apps/storefront/scripts/netlify-config.mjs create mode 100644 apps/storefront/scripts/netlify-config.test.mjs create mode 100644 apps/storefront/scripts/sitemap-url.mjs create mode 100644 apps/storefront/scripts/sitemap-url.test.mjs create mode 100644 apps/storefront/src/lib/asyncSearchCache.test.ts create mode 100644 apps/storefront/src/lib/asyncSearchCache.ts create mode 100644 packages/ui/src/layout/StorefrontChromeMockup.test.ts diff --git a/apps/storefront/.env.example b/apps/storefront/.env.example index 2381e6d..4702126 100644 --- a/apps/storefront/.env.example +++ b/apps/storefront/.env.example @@ -24,22 +24,9 @@ CMS_TAILWIND_BOOTSTRAP=false # https://dev.superfunky.pro/graphql reference backend when this is empty. Set an # explicit endpoint for every customer or production deployment. # -# Web Push subscribe, unsubscribe, and public-key routes are derived from this backend -# origin. VITE_VAPID_PUBLIC_KEY may optionally pin the public VAPID key at build time; -# never expose the private VAPID key to the frontend. -VITE_VAPID_PUBLIC_KEY= - -# Stripe publishable key (test or live) — set this to render the real -# @stripe/react-stripe-js Payment Element on the checkout page's "Pay online with -# card" option. Left unset, the checkout page shows a "connect Stripe" notice instead. -# -# IMPORTANT: only the *publishable* key (pk_...) ever belongs here. The Stripe -# *secret* key must NEVER be set in this frontend, in any .env file, or committed -# anywhere in this repo — it is configured once in wp-admin under WooCommerce → -# Settings → Payments → Stripe (the WooCommerce Stripe Gateway plugin), which is what -# lib/payments.ts's checkout submission actually talks to via the WooCommerce Store -# API. This frontend never creates PaymentIntents or touches the secret key directly. -VITE_STRIPE_PUBLISHABLE_KEY= +# Web Push subscribe, unsubscribe, and VAPID public-key routes are derived from this +# backend origin. The Stripe publishable key is likewise supplied by backend store +# settings. Private VAPID and Stripe keys must never be exposed to the frontend. # Edge/CDN geolocation endpoint (see lib/geolocation.ts) — typically injected by the # hosting platform (e.g. Netlify's /geolocation) rather than the WP backend itself, so diff --git a/apps/storefront/scripts/bootstrap-performance.test.mjs b/apps/storefront/scripts/bootstrap-performance.test.mjs index 9c4f4a9..a229866 100644 --- a/apps/storefront/scripts/bootstrap-performance.test.mjs +++ b/apps/storefront/scripts/bootstrap-performance.test.mjs @@ -449,7 +449,7 @@ test("interaction intent prepares code without replacing the SSG document", () = test("fleet prerendered CMS content mounts lightweight native behaviors while idle", () => { assert.match(mainSource, /if \(!isFlagshipStorefront \|\| deferFlagshipHomeHydration\) \{[\s\S]*import\("\.\/lib\/cmsBehaviors"\)/); - assert.match(mainSource, /mountCmsBehaviors\(content\)/); + assert.match(mainSource, /mountCmsBehaviors\(content, content\.dataset\.showCodeControls !== "false"\)/); assert.match(mainSource, /stopStaticCmsBehaviors\(\)/); assert.match(mainSource, /Static CMS behaviors could not be loaded/); }); diff --git a/apps/storefront/scripts/netlify-config.mjs b/apps/storefront/scripts/netlify-config.mjs new file mode 100644 index 0000000..7f8c13a --- /dev/null +++ b/apps/storefront/scripts/netlify-config.mjs @@ -0,0 +1,67 @@ +const MAX_FILE_BYTES = 65_536; +const MAX_LINES = 1_000; +const MAX_LINE_BYTES = 4_096; +const CONTROL_CHARACTERS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/; +const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; + +function normalizeLines(contents, label) { + if (typeof contents !== "string") { + throw new Error(`${label} must be text.`); + } + const normalized = contents.replaceAll("\r\n", "\n"); + if (Buffer.byteLength(normalized, "utf8") > MAX_FILE_BYTES) { + throw new Error(`${label} exceeds the 64 KB limit.`); + } + if (normalized.includes("\r") || CONTROL_CHARACTERS.test(normalized)) { + throw new Error(`${label} contains unsupported control characters.`); + } + const lines = normalized.split("\n"); + if (lines.length > MAX_LINES) { + throw new Error(`${label} exceeds the 1,000-line limit.`); + } + for (const [index, line] of lines.entries()) { + if (Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) { + throw new Error(`${label} line ${index + 1} exceeds the 4 KB line limit.`); + } + } + return { normalized, lines }; +} + +export function validateNetlifyRedirects(contents = "") { + const { normalized, lines } = normalizeLines(contents, "Netlify _redirects"); + for (const [index, line] of lines.entries()) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const parts = trimmed.split(/\s+/); + if (parts.length < 2 || !parts[0].startsWith("/")) { + throw new Error(`Netlify _redirects line ${index + 1} must contain a source path and destination.`); + } + if (parts[2] && !/^[2-5]\d\d!?$/.test(parts[2])) { + throw new Error(`Netlify _redirects line ${index + 1} has an invalid redirect status.`); + } + if (parts.slice(3).some((option) => !/^[A-Za-z][A-Za-z0-9_-]*=[A-Za-z0-9_.*,:-]+$/.test(option))) { + throw new Error(`Netlify _redirects line ${index + 1} has an invalid redirect option.`); + } + } + return normalized; +} + +export function validateNetlifyHeaders(contents = "") { + const { normalized, lines } = normalizeLines(contents, "Netlify _headers"); + let hasPath = false; + for (const [index, line] of lines.entries()) { + if (!line.trim() || /^\s*#/.test(line)) continue; + if (/^[ \t]/.test(line)) { + const match = line.match(/^[ \t]+([^:]+):[ \t]*(.*)$/); + if (!hasPath || !match || !HEADER_NAME.test(match[1]) || CONTROL_CHARACTERS.test(match[2])) { + throw new Error(`Netlify _headers line ${index + 1} must be a valid indented header following a path.`); + } + continue; + } + if (!line.trim().startsWith("/") || /\s/.test(line.trim())) { + throw new Error(`Netlify _headers line ${index + 1} must be a path beginning with /.`); + } + hasPath = true; + } + return normalized; +} diff --git a/apps/storefront/scripts/netlify-config.test.mjs b/apps/storefront/scripts/netlify-config.test.mjs new file mode 100644 index 0000000..2337791 --- /dev/null +++ b/apps/storefront/scripts/netlify-config.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { validateNetlifyHeaders, validateNetlifyRedirects } from "./netlify-config.mjs"; + +test("validates and preserves raw Netlify redirect rules", () => { + const rules = "# custom routes\n/old-path /new-path 301!\n/shop/* https://shop.example/:splat 302 Country=US\n"; + assert.equal(validateNetlifyRedirects(rules), rules); + assert.equal(validateNetlifyRedirects(""), ""); +}); + +test("rejects malformed, oversized, and control-character redirect rules", () => { + assert.throws(() => validateNetlifyRedirects("old-path /new-path 301"), /source path and destination/); + assert.throws(() => validateNetlifyRedirects("/old /new nope"), /invalid redirect status/); + assert.throws(() => validateNetlifyRedirects(`/old /new 301\n${"x".repeat(4097)}`), /4 KB line limit/); + assert.throws(() => validateNetlifyRedirects(`/old /new 301\n${"x".repeat(66_000)}`), /64 KB limit/); + assert.throws(() => validateNetlifyRedirects("/old /new 301\u0000"), /control characters/); +}); + +test("validates raw Netlify headers in path blocks", () => { + const headers = "/*\n X-Frame-Options: SAMEORIGIN\n Cache-Control: public, max-age=0\n/private/*\n X-Robots-Tag: noindex\n"; + assert.equal(validateNetlifyHeaders(headers), headers); + assert.equal(validateNetlifyHeaders(""), ""); + assert.throws(() => validateNetlifyHeaders(" X-Frame-Options: DENY"), /following a path/); + assert.throws(() => validateNetlifyHeaders("/private\nX-Robots-Tag: noindex"), /path beginning with \//); + assert.throws(() => validateNetlifyHeaders("/private\n Bad Header: nope"), /indented header/); +}); + +test("static generation validates and merges backend rules with generated Netlify files", async () => { + const source = await readFile(new URL("./prerender.mjs", import.meta.url), "utf8"); + assert.match(source, /staticGenerationConfig\.netlifyRedirects = validateNetlifyRedirects/); + assert.match(source, /staticGenerationConfig\.netlifyHeaders = validateNetlifyHeaders/); + const customRedirects = source.indexOf("...staticGenerationConfig.netlifyRedirects.split"); + const spaFallback = source.indexOf('"/* /index.html 200"', customRedirects); + assert.ok(customRedirects >= 0 && spaFallback > customRedirects, "custom redirects must precede the SPA fallback"); + assert.match(source, /"X-Frame-Options"/, "generated baseline security headers must remain present"); + assert.match(source, /\.\.\.staticGenerationConfig\.netlifyHeaders\.split/); + assert.match(source, /resolve\(outputDirectory, "_redirects"\)/); + assert.match(source, /resolve\(outputDirectory, "_headers"\)/); +}); diff --git a/apps/storefront/scripts/prerender.mjs b/apps/storefront/scripts/prerender.mjs index 5f8fe1e..447cac8 100644 --- a/apps/storefront/scripts/prerender.mjs +++ b/apps/storefront/scripts/prerender.mjs @@ -35,6 +35,7 @@ import { normalizeLanguageRoutePath, prerenderRouteDirectoryPath, } from "./route-paths.mjs"; +import { sitemapUrl } from "./sitemap-url.mjs"; import { backendPostUriFromStorefrontPath } from "../src/lib/postRoutePaths.mjs"; import { resolveTaxonomyArchiveIdentifier } from "../src/lib/taxonomyRoutes.ts"; import { @@ -56,6 +57,7 @@ import { import { stableRouteIsAvailable } from "./route-availability.mjs"; import { navigationDataCacheKey } from "../src/lib/navigationCacheKey.mjs"; import { staticNavHrefMatchesRoute as matchesStaticNavigationRoute } from "./static-navigation-route.mjs"; +import { validateNetlifyHeaders, validateNetlifyRedirects } from "./netlify-config.mjs"; const staticNavigationRuntimeSource = await readFile( new URL("../src/lib/staticNavigationRuntime.js", import.meta.url), @@ -482,7 +484,7 @@ const DEFAULT_STATIC_CHROME = { stylesheets: [], colors: [], headerControls: DEFAULT_STATIC_HEADER_CONTROLS, - themeCredit: 'Made with superfuky WP theme by Coded Letter.', + themeCredit: 'Made with FunkyCommerce WordPress theme by Coded Letter.', showThemeCredit: true, recentOrders: { enabled: false, @@ -565,6 +567,8 @@ const DEFAULT_STATIC_GENERATION_CONFIG = { aiDefenseTxt: "", appleMerchantFile: "", redirectRules: "[]", + netlifyRedirects: "", + netlifyHeaders: "", securityHeadersEnabled: true, securityHeaders: JSON.stringify(DEFAULT_SECURITY_HEADERS), gtmContainerId: "", @@ -601,10 +605,11 @@ function frontendUrl(value, fallbackPath = "/") { } } -function renderSeoHead(route) { +function renderSeoHead(route, configuredSiteName = "FunkyCommerce") { const canonical = frontendUrl(route.canonical, route.path); - const description = route.description?.trim() || "Explore FunkyCommerce."; - const title = route.title?.trim() || "FunkyCommerce"; + const description = route.description?.trim() || `Explore ${siteName}.`; + const title = route.title?.trim() || siteName; + const siteName = route.opengraphSiteName?.trim() || configuredSiteName || "FunkyCommerce"; const robots = route.robots || (route.indexable ? "index, follow" : "noindex, follow"); const image = route.image; const imageType = image?.type || imageTypeFromUrl(image?.url); @@ -619,7 +624,8 @@ function renderSeoHead(route) { ``, ``, canonical ? `` : "", - ``, + ``, + ``, ``, image ? `` : "", image?.url?.startsWith("https://") ? `` : "", @@ -743,6 +749,8 @@ async function requestGraphql( { cause: graphqlTransportFailure.error }, ); } + staticGenerationConfig.netlifyRedirects = validateNetlifyRedirects(staticGenerationConfig.netlifyRedirects); + staticGenerationConfig.netlifyHeaders = validateNetlifyHeaders(staticGenerationConfig.netlifyHeaders); let lastError; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { @@ -1902,7 +1910,7 @@ async function renderRoute(route) { const heroPreload = heroImage ? `\n ` : ""; - const seoHead = renderSeoHead(route); + const seoHead = renderSeoHead(route, routeChromeConfig.storeName); const staticTheme = renderStaticThemeVariables( routeChromeConfig.colors, routeChromeConfig.brandPalette, @@ -1932,6 +1940,7 @@ async function renderRoute(route) { ``, ) .replace(/\s*]*)?>.*?<\/title>/, "") + .replace(/\s*/, "") .replace( /\s*/, `\n ${seoHead}${heroPreload}${earlyPreloadHints ? `\n ${earlyPreloadHints}` : ""}`, @@ -1982,7 +1991,7 @@ async function renderRoute(route) { rendered = stripBootstrapOverlay( rendered.replace( '
', - `
${staticChrome}
${staticBreadcrumbs}
${routeSnapshot}
${staticFooter}${renderStaticFloatingControls(route)}
`, + `
${staticChrome}
${staticBreadcrumbs}
${routeSnapshot}
${staticFooter}${renderStaticFloatingControls(route)}
`, ), ); } @@ -3485,6 +3494,7 @@ function renderRedirects(appleMerchantFileEnabled) { ...opaqueMediaProxy, ...mediaDocumentProxy, ...rewriteRules, + ...staticGenerationConfig.netlifyRedirects.split("\n").filter((line) => line.trim()), "/* /index.html 200", "", ].join("\n"); @@ -3543,6 +3553,7 @@ function renderHeaders() { " Content-Type: text/plain; charset=UTF-8", " Cache-Control: public, max-age=0, must-revalidate", "", + ...staticGenerationConfig.netlifyHeaders.split("\n"), ].join("\n"); } @@ -3782,7 +3793,7 @@ await writeFile( const sitemapXml = [ '', '', - ...sitemapRoutes.map(({ path }) => ` ${escapeAttribute(`${sitemapOrigin}${path === "/" ? "" : path}`)}`), + ...sitemapRoutes.map(({ path }) => ` ${escapeAttribute(sitemapUrl(sitemapOrigin, path))}`), "", "", ].join("\n"); diff --git a/apps/storefront/scripts/route-paths.mjs b/apps/storefront/scripts/route-paths.mjs index 267a1b9..bf0b4a4 100644 --- a/apps/storefront/scripts/route-paths.mjs +++ b/apps/storefront/scripts/route-paths.mjs @@ -136,7 +136,7 @@ export function cmsRouteFromNode(node, connectionName, defaultLanguage = "en", c opengraphModifiedTime: seo.opengraphModifiedTime?.trim() || node?.modified?.trim() || "", opengraphPublishedTime: seo.opengraphPublishedTime?.trim() || node?.date?.trim() || "", opengraphPublisher: seo.opengraphPublisher?.trim() || "", - opengraphSiteName: seo.opengraphSiteName?.trim() || "FunkyCommerce", + opengraphSiteName: seo.opengraphSiteName?.trim() || "", opengraphTitle: seo.opengraphTitle?.trim() || "", opengraphType: isProductArchive ? "website" : seo.opengraphType?.trim() || (type === "Post" ? "article" : type?.includes("Product") ? "product" : "website"), twitterDescription: seo.twitterDescription?.trim() || "", diff --git a/apps/storefront/scripts/seo-metadata.test.mjs b/apps/storefront/scripts/seo-metadata.test.mjs index da2f30c..d1e58b7 100644 --- a/apps/storefront/scripts/seo-metadata.test.mjs +++ b/apps/storefront/scripts/seo-metadata.test.mjs @@ -32,11 +32,15 @@ test("prerender emits complete CMS metadata in the first document response", asy 'property="og:image:height"', 'property="article:published_time"', 'name="twitter:image:alt"', + 'name="apple-mobile-web-app-title"', 'type="application/ld+json"', "data-storefront-seo", ]) { assert.ok(prerender.includes(metadata), `Expected prerender metadata: ${metadata}`); } + assert.match(prerender, /renderSeoHead\(route, routeChromeConfig\.storeName\)/); + assert.match(prerender, / { assert.match(shortcodes, /resolveShortcodeCta\(attributes, "primary"\)/); assert.match(shortcodeCta, /\$\{position\}-cta-target/); assert.match(shortcodeCta, /\$\{position\}-cta-rel/); - assert.match(shortcodeCta, /target === "_blank"[\s\S]*relTokens\.push\("noopener"\)/); + assert.match(shortcodeCta, /if \(target === "_blank"\) \{[\s\S]*for \(const token of \["noopener", "noreferrer"\]\)[\s\S]*relTokens\.includes\(token\)[\s\S]*relTokens\.push\(token\)/); assert.match(hero, /target=\{cta\.target\}/); assert.match(hero, /rel=\{cta\.rel\}/); }); diff --git a/apps/storefront/scripts/sitemap-url.mjs b/apps/storefront/scripts/sitemap-url.mjs new file mode 100644 index 0000000..f85e7b2 --- /dev/null +++ b/apps/storefront/scripts/sitemap-url.mjs @@ -0,0 +1,12 @@ +export function sitemapUrl(origin, routePath) { + const site = new URL(origin); + const url = new URL(routePath, site); + if (!/^https?:$/.test(site.protocol) || url.origin !== site.origin) { + throw new Error("Sitemap routes must resolve to the configured storefront origin."); + } + + const pathname = url.pathname.replace(/\/+$/, ""); + url.pathname = pathname ? `${pathname}/` : "/"; + url.hash = ""; + return url.href; +} diff --git a/apps/storefront/scripts/sitemap-url.test.mjs b/apps/storefront/scripts/sitemap-url.test.mjs new file mode 100644 index 0000000..a0b65be --- /dev/null +++ b/apps/storefront/scripts/sitemap-url.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { sitemapUrl } from "./sitemap-url.mjs"; + +test("sitemap locations end with one slash, including the storefront root", () => { + assert.equal(sitemapUrl("https://shop.example.test", "/"), "https://shop.example.test/"); + assert.equal(sitemapUrl("https://shop.example.test", "/shop/item"), "https://shop.example.test/shop/item/"); + assert.equal(sitemapUrl("https://shop.example.test", "/shop/item///"), "https://shop.example.test/shop/item/"); +}); + +test("sitemap locations reject external routes and strip fragments", () => { + assert.throws(() => sitemapUrl("https://shop.example.test", "https://outside.example/test")); + assert.equal( + sitemapUrl("https://shop.example.test", "/shop/item#details"), + "https://shop.example.test/shop/item/", + ); +}); diff --git a/apps/storefront/src/components/wordpressShortcodes.tsx b/apps/storefront/src/components/wordpressShortcodes.tsx index 1fada3c..7305429 100644 --- a/apps/storefront/src/components/wordpressShortcodes.tsx +++ b/apps/storefront/src/components/wordpressShortcodes.tsx @@ -128,7 +128,7 @@ function VideoHeroShortcode({ attributes }: ShortcodeProps) { } function SpotifyRadioShortcode({ attributes }: ShortcodeProps) { - const title = attributes.title || "Superfunky Radio"; + const title = attributes.title || "Store radio"; const contentType = oneOf( attributes["content-type"], ["track", "album", "playlist", "artist", "show", "episode"] as const, diff --git a/apps/storefront/src/lib/asyncSearchCache.test.ts b/apps/storefront/src/lib/asyncSearchCache.test.ts new file mode 100644 index 0000000..39b13dd --- /dev/null +++ b/apps/storefront/src/lib/asyncSearchCache.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createAsyncSearchCache } from "./asyncSearchCache.ts"; + +test("deduplicates concurrent searches and briefly caches successful results", async () => { + let calls = 0; + const cache = createAsyncSearchCache({ ttlMs: 1000 }); + const load = async () => { + calls += 1; + await Promise.resolve(); + return ["result"]; + }; + + const [first, second] = await Promise.all([ + cache("en:coat", load), + cache("en:coat", load), + ]); + assert.deepEqual(first, ["result"]); + assert.deepEqual(second, ["result"]); + assert.deepEqual(await cache("en:coat", load), ["result"]); + assert.equal(calls, 1); +}); + +test("does not cache failed searches", async () => { + let calls = 0; + const cache = createAsyncSearchCache({ ttlMs: 1000 }); + const load = async () => { + calls += 1; + if (calls === 1) throw new Error("temporary"); + return ["result"]; + }; + + await assert.rejects(cache("en:coat", load), /temporary/); + assert.deepEqual(await cache("en:coat", load), ["result"]); + assert.equal(calls, 2); +}); + +test("expires results and bounds its memory use", async () => { + let now = 0; + const cache = createAsyncSearchCache({ ttlMs: 10, maxEntries: 1, now: () => now }); + const load = (value: string) => async () => value; + + assert.equal(await cache("one", load("first")), "first"); + assert.equal(await cache("two", load("second")), "second"); + assert.equal(await cache("one", load("reloaded")), "reloaded"); + now = 20; + assert.equal(await cache("one", load("expired")), "expired"); +}); diff --git a/apps/storefront/src/lib/asyncSearchCache.ts b/apps/storefront/src/lib/asyncSearchCache.ts new file mode 100644 index 0000000..02118b7 --- /dev/null +++ b/apps/storefront/src/lib/asyncSearchCache.ts @@ -0,0 +1,45 @@ +type CacheEntry = { + expiresAt: number; + value: T; +}; + +export function createAsyncSearchCache( + { ttlMs = 15_000, maxEntries = 48, now = Date.now }: { + ttlMs?: number; + maxEntries?: number; + now?: () => number; + } = {}, +) { + const cached = new Map>(); + const pending = new Map>(); + + return (key: string, load: () => Promise): Promise => { + const entry = cached.get(key); + if (entry && entry.expiresAt > now()) { + cached.delete(key); + cached.set(key, entry); + return Promise.resolve(entry.value); + } + if (entry) cached.delete(key); + + const request = pending.get(key); + if (request) return request; + + const next = Promise.resolve() + .then(load) + .then((value) => { + cached.delete(key); + cached.set(key, { value, expiresAt: now() + ttlMs }); + if (cached.size > maxEntries) { + const oldestKey = cached.keys().next().value; + if (oldestKey !== undefined) cached.delete(oldestKey); + } + return value; + }) + .finally(() => { + pending.delete(key); + }); + pending.set(key, next); + return next; + }; +} diff --git a/apps/storefront/src/lib/cmsBehaviors.test.ts b/apps/storefront/src/lib/cmsBehaviors.test.ts index 3f1933d..4123721 100644 --- a/apps/storefront/src/lib/cmsBehaviors.test.ts +++ b/apps/storefront/src/lib/cmsBehaviors.test.ts @@ -88,6 +88,22 @@ test("known docs behavior mounts after render and remounts after a route transit routeCleanup(); }); +test("homepage location uses the configured storefront name", () => { + const meta = document.createElement("meta"); + meta.setAttribute("property", "og:site_name"); + meta.content = "Example Shop"; + document.head.append(meta); + const root = document.querySelector("#cms-root")!; + root.innerHTML = '
'; + + const cleanup = mountCmsBehaviors(root); + const locationPanel = root.querySelector(".cms-home-location"); + assert.equal(locationPanel?.querySelector("strong")?.textContent, "Example Shop"); + assert.equal(locationPanel?.getAttribute("aria-label"), "Example Shop location"); + + cleanup(); +}); + test("autoplay CMS videos are resumed on mount and when the page becomes visible", async () => { const root = document.querySelector("#cms-root")!; root.innerHTML = ``; @@ -167,6 +183,22 @@ test("native and standalone code use local Prism language and color selection", assert.equal(root.querySelector(".cms-code-controls"), null); }); +test("global code controls follow the configured setting and can be toggled back on", async () => { + const root = document.querySelector("#cms-root")!; + root.innerHTML = `
const total = 2;
`; + + const disabledCleanup = mountCmsBehaviors(root, false); + await waitFor(() => root.querySelector("code")?.dataset.cmsHighlighted === "true"); + assert.equal(root.querySelector(".cms-code-controls"), null); + disabledCleanup(); + + const enabledCleanup = mountCmsBehaviors(root, true); + await waitFor(() => root.querySelector(".cms-code-controls") !== null); + assert.equal(root.querySelectorAll(".cms-code-controls").length, 1); + enabledCleanup(); + assert.equal(root.querySelector(".cms-code-controls"), null); +}); + test("approved Superfunky documentation behavior tracks the heading in the viewport", () => { const root = document.querySelector("#cms-root")!; root.innerHTML = ` diff --git a/apps/storefront/src/lib/cmsBehaviors.ts b/apps/storefront/src/lib/cmsBehaviors.ts index d3c7cb5..14745a6 100644 --- a/apps/storefront/src/lib/cmsBehaviors.ts +++ b/apps/storefront/src/lib/cmsBehaviors.ts @@ -598,8 +598,10 @@ function mountHomepageLocation(container: HTMLElement): Cleanup { const originalLabel = container.getAttribute("aria-label"); const panel = document.createElement("div"); panel.className = "cms-home-location-panel"; + const storeName = document.querySelector('meta[property="og:site_name"]')?.content.trim() + || "FunkyCommerce"; const label = document.createElement("strong"); - label.textContent = "Superfunky"; + label.textContent = storeName; const description = document.createElement("span"); description.textContent = "The e-commerce theme for modern stores"; const link = document.createElement("a"); @@ -611,7 +613,7 @@ function mountHomepageLocation(container: HTMLElement): Cleanup { container.replaceChildren(panel); container.classList.add("cms-home-location"); container.setAttribute("role", "region"); - container.setAttribute("aria-label", "Superfunky location"); + container.setAttribute("aria-label", `${storeName} location`); return () => { container.innerHTML = originalHtml; diff --git a/apps/storefront/src/lib/navigation.ts b/apps/storefront/src/lib/navigation.ts index a335bed..305ee10 100644 --- a/apps/storefront/src/lib/navigation.ts +++ b/apps/storefront/src/lib/navigation.ts @@ -478,8 +478,8 @@ export const DEFAULT_STOREFRONT_CONFIGURATION: StorefrontConfiguration = { stripePublishableKey: null, stripeCustomerPortalUrl: null, branding: { - storeName: "Superfunky", - companyName: "Superfunky", + storeName: "FunkyCommerce", + companyName: "FunkyCommerce", tagline: "Modern storefront mockup", logoUrl: null, iconUrl: null, @@ -521,7 +521,7 @@ export const DEFAULT_STOREFRONT_CONFIGURATION: StorefrontConfiguration = { spotifyPlayerDescription: "", extraHtml: "", copyrightText: "", - themeCredit: 'Made with superfuky WP theme by Coded Letter.', + themeCredit: 'Made with FunkyCommerce WordPress theme by Coded Letter.', showThemeCredit: true, }, recentOrders: { diff --git a/apps/storefront/src/lib/payments.ts b/apps/storefront/src/lib/payments.ts index bd50db2..baf03f8 100644 --- a/apps/storefront/src/lib/payments.ts +++ b/apps/storefront/src/lib/payments.ts @@ -248,7 +248,7 @@ function paymentGatewayAvailability( isCryptoAvailable: (snapshot?.ids.has("funkycommerce_crypto") ?? false) && cryptoAssets.length > 0, - cryptoGatewayTitle: snapshot?.gateways.get("funkycommerce_crypto")?.title || "Superfunky Crypto Wallet", + cryptoGatewayTitle: snapshot?.gateways.get("funkycommerce_crypto")?.title || "Crypto Wallet", cryptoGatewayDescription: snapshot?.gateways.get("funkycommerce_crypto")?.description || "Pay directly with one of the configured store wallets.", diff --git a/apps/storefront/src/lib/profileHeaderSharing.test.ts b/apps/storefront/src/lib/profileHeaderSharing.test.ts index 746ddbc..41c1f8c 100644 --- a/apps/storefront/src/lib/profileHeaderSharing.test.ts +++ b/apps/storefront/src/lib/profileHeaderSharing.test.ts @@ -76,7 +76,9 @@ test("avatar editing updates the account cache independently and invalidates pro assert.match(account, /invalidateIncrementalDataPrefix\("community-archive-data:v1:"\)/); assert.match(account, /invalidateIncrementalDataPrefix\("author:v2:"\)/); assert.match(account, /invalidateIncrementalDataPrefix\("blog-data:"\)/); - assert.match(incrementalData, /entry\.cacheKey\.startsWith\("community:v11:"\)/); - assert.match(incrementalData, /entry\.cacheKey\.startsWith\("author:v2:"\)/); + assert.match(incrementalData, /"community:v11:"/); + assert.match(incrementalData, /hydrationCachePrefixes\.some\(\(prefix\) => entry\.cacheKey\.startsWith\(prefix\)\)/); + assert.match(incrementalData, /"author:v2:"/); + assert.match(incrementalData, /hydrationCachePrefixes\.some\(\(prefix\) => entry\.cacheKey\.startsWith\(prefix\)\)/); assert.match(authorPage, /`author:v2:/); }); diff --git a/apps/storefront/src/lib/push.ts b/apps/storefront/src/lib/push.ts index 70f0e6c..450284d 100644 --- a/apps/storefront/src/lib/push.ts +++ b/apps/storefront/src/lib/push.ts @@ -1,7 +1,6 @@ import { restUrl } from "@funky/sdk"; import { getAuthTokenForRequest } from "./auth"; -const VAPID_PUBLIC_KEY = (import.meta.env.VITE_VAPID_PUBLIC_KEY as string | undefined)?.trim(); const PUSH_BASE_ENDPOINT = restUrl("funkycommerce/v1/push"); const PUSH_SUBSCRIBE_ENDPOINT = PUSH_BASE_ENDPOINT ? `${PUSH_BASE_ENDPOINT}/subscribe` : undefined; const PUSH_UNSUBSCRIBE_ENDPOINT = PUSH_BASE_ENDPOINT ? `${PUSH_BASE_ENDPOINT}/unsubscribe` : undefined; @@ -11,7 +10,7 @@ const VAPID_ENDPOINT = PUSH_BASE_ENDPOINT ? `${PUSH_BASE_ENDPOINT}/vapid-public- export const isPushSupported = typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; -export const isPushBackendConfigured = Boolean(PUSH_SUBSCRIBE_ENDPOINT && (VAPID_PUBLIC_KEY || VAPID_ENDPOINT)); +export const isPushBackendConfigured = Boolean(PUSH_SUBSCRIBE_ENDPOINT && VAPID_ENDPOINT); export type PushPermission = NotificationPermission | "unsupported"; @@ -44,7 +43,6 @@ async function requireSuccessfulResponse(response: Response, fallback: string): } async function resolveVapidPublicKey(): Promise { - if (VAPID_PUBLIC_KEY) return VAPID_PUBLIC_KEY; if (!VAPID_ENDPOINT) throw new PushBackendError("The push backend is not configured."); let response: Response; diff --git a/apps/storefront/src/lib/search.ts b/apps/storefront/src/lib/search.ts index 36bcdff..3527b01 100644 --- a/apps/storefront/src/lib/search.ts +++ b/apps/storefront/src/lib/search.ts @@ -17,15 +17,43 @@ import { SINGLE_LANGUAGE_SEARCH_QUERY, } from "./searchQuery.ts"; import { searchWordPressRest } from "./searchRest.ts"; +import { createAsyncSearchCache } from "./asyncSearchCache.ts"; export { mapStorefrontSearchResults, SEARCH_QUERY }; export type { StorefrontSearchQueryResult }; -export async function searchStorefront( +const cachedSearch = createAsyncSearchCache({ + ttlMs: 15_000, + maxEntries: 48, +}); + +export function searchStorefront( query: string, backendLanguageCode: string, routeLanguageCode: string, t: (key: string) => string = (key) => key, +): Promise { + const normalizedQuery = query.trim(); + if (normalizedQuery.length < 2) return Promise.resolve([]); + const cacheKey = JSON.stringify([ + STOREFRONT_BACKEND_PROFILE, + backendLanguageCode, + routeLanguageCode, + normalizedQuery.toLocaleLowerCase(), + ]); + return cachedSearch(cacheKey, () => fetchStorefrontSearch( + normalizedQuery, + backendLanguageCode, + routeLanguageCode, + t, + )); +} + +async function fetchStorefrontSearch( + query: string, + backendLanguageCode: string, + routeLanguageCode: string, + t: (key: string) => string, ): Promise { if (STOREFRONT_BACKEND_PROFILE === "blog") { return searchWordPressRest(query, routeLanguageCode, t, { diff --git a/apps/storefront/src/lib/shortcodeCta.ts b/apps/storefront/src/lib/shortcodeCta.ts index ff05ae8..9e2d1ab 100644 --- a/apps/storefront/src/lib/shortcodeCta.ts +++ b/apps/storefront/src/lib/shortcodeCta.ts @@ -29,7 +29,11 @@ export function resolveShortcodeCta( .toLowerCase() .split(/\s+/) .filter((token) => allowedRel.has(token)); - if (target === "_blank" && !relTokens.includes("noopener")) relTokens.push("noopener"); + if (target === "_blank") { + for (const token of ["noopener", "noreferrer"]) { + if (!relTokens.includes(token)) relTokens.push(token); + } + } return { label, diff --git a/apps/storefront/src/lib/shortcodeMarkup.test.ts b/apps/storefront/src/lib/shortcodeMarkup.test.ts index 23df222..ae71ede 100644 --- a/apps/storefront/src/lib/shortcodeMarkup.test.ts +++ b/apps/storefront/src/lib/shortcodeMarkup.test.ts @@ -171,7 +171,7 @@ test("supports safe new-tab CTAs for heroes and cinematic sliders", () => { label: "Journal", href: "/blog", target: "_blank", - rel: "noopener", + rel: "noopener noreferrer", }); }); diff --git a/apps/storefront/src/lib/stripe.ts b/apps/storefront/src/lib/stripe.ts index e4dfc2a..6716455 100644 --- a/apps/storefront/src/lib/stripe.ts +++ b/apps/storefront/src/lib/stripe.ts @@ -1,7 +1,7 @@ import { loadStripe } from "@stripe/stripe-js/pure"; import type { Stripe } from "@stripe/stripe-js"; -let publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string | undefined; +let publishableKey: string | undefined; export function setStripePublishableKey(key: string | null | undefined): void { if (key && key.startsWith("pk_")) { @@ -11,8 +11,9 @@ export function setStripePublishableKey(key: string | null | undefined): void { } return; } - if (!publishableKey) { + if (publishableKey !== undefined) { publishableKey = undefined; + stripePromise = null; } } diff --git a/apps/storefront/src/main.tsx b/apps/storefront/src/main.tsx index c5e1bff..57eefb8 100644 --- a/apps/storefront/src/main.tsx +++ b/apps/storefront/src/main.tsx @@ -590,7 +590,7 @@ if (prerenderRoot) { if (!content || disposed || activationRequested) return; void import("./lib/cmsBehaviors").then(({ mountCmsBehaviors }) => { if (disposed || activationRequested) return; - const cleanup = mountCmsBehaviors(content); + const cleanup = mountCmsBehaviors(content, content.dataset.showCodeControls !== "false"); stopStaticCmsBehaviors = () => { disposed = true; cleanup(); diff --git a/apps/storefront/src/pages/AuthMockupPage.tsx b/apps/storefront/src/pages/AuthMockupPage.tsx index 411fcd8..d1f8787 100644 --- a/apps/storefront/src/pages/AuthMockupPage.tsx +++ b/apps/storefront/src/pages/AuthMockupPage.tsx @@ -403,7 +403,7 @@ function RegisterFormMock({ return (
-
+
void; }) { + const t = useT(); const stripe = useStripe(); const elements = useElements(); const selectedPaymentType = useRef("card"); @@ -2025,9 +2026,7 @@ function StripeCardElement({ Stripe isn't connected yet

- Configure the Stripe publishable key in the store settings or set{" "} - VITE_STRIPE_PUBLISHABLE_KEY{" "} - to render the secure card form. + Configure Stripe's publishable key in the backend store settings to render the secure card form.

); @@ -2040,7 +2039,7 @@ function StripeCardElement({

); diff --git a/apps/storefront/src/pages/CommunityPostMockupPage.tsx b/apps/storefront/src/pages/CommunityPostMockupPage.tsx index c017f91..d7968ee 100644 --- a/apps/storefront/src/pages/CommunityPostMockupPage.tsx +++ b/apps/storefront/src/pages/CommunityPostMockupPage.tsx @@ -47,7 +47,7 @@ export function CommunityPostMockupPage() { const { hasLanguagePreference, languageCode, syncLanguageCode } = useLanguage(); const creatorContent = useCreatorContent(); const { data: liveCommunity, viewer, refresh } = useCommunityData(); - const { discussionLayout } = useLayoutPreferences(); + const { discussionLayout, showCodeControls } = useLayoutPreferences(); const [postRevision, setPostRevision] = useState(0); const [isEditOpen, setIsEditOpen] = useState(false); const [deleteError, setDeleteError] = useState(null); @@ -112,8 +112,8 @@ export function CommunityPostMockupPage() { useEffect(() => { if (!contentRef.current || !wordpressPost?.contentHtml) return; - return mountCmsBehaviors(contentRef.current); - }, [wordpressPost?.contentHtml]); + return mountCmsBehaviors(contentRef.current, showCodeControls); + }, [showCodeControls, wordpressPost?.contentHtml]); if (!post && isDirectPostLoading) { return ; diff --git a/apps/storefront/src/pages/LayoutStudioMockupPage.tsx b/apps/storefront/src/pages/LayoutStudioMockupPage.tsx index 57bda14..5093109 100644 --- a/apps/storefront/src/pages/LayoutStudioMockupPage.tsx +++ b/apps/storefront/src/pages/LayoutStudioMockupPage.tsx @@ -1170,7 +1170,7 @@ export function LayoutStudioMockupPage() { icon={