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}${staticFooter}${renderStaticFloatingControls(route)}
`,
+ `${staticChrome}
${staticBreadcrumbs}${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 (