Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 3 additions & 16 deletions apps/storefront/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/storefront/scripts/bootstrap-performance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Expand Down
67 changes: 67 additions & 0 deletions apps/storefront/scripts/netlify-config.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 40 additions & 0 deletions apps/storefront/scripts/netlify-config.test.mjs
Original file line number Diff line number Diff line change
@@ -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"\)/);
});
27 changes: 19 additions & 8 deletions apps/storefront/scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
Expand Down Expand Up @@ -482,7 +484,7 @@ const DEFAULT_STATIC_CHROME = {
stylesheets: [],
colors: [],
headerControls: DEFAULT_STATIC_HEADER_CONTROLS,
themeCredit: 'Made with <a href="https://superfunky.pro" target="_blank" rel="noopener noreferrer">superfuky WP theme</a> by <a href="https://codedletter.com" target="_blank" rel="noopener noreferrer">Coded Letter</a>.',
themeCredit: 'Made with <a href="https://superfunky.pro" target="_blank" rel="noopener noreferrer">FunkyCommerce WordPress theme</a> by <a href="https://codedletter.com" target="_blank" rel="noopener noreferrer">Coded Letter</a>.',
showThemeCredit: true,
recentOrders: {
enabled: false,
Expand Down Expand Up @@ -565,6 +567,8 @@ const DEFAULT_STATIC_GENERATION_CONFIG = {
aiDefenseTxt: "",
appleMerchantFile: "",
redirectRules: "[]",
netlifyRedirects: "",
netlifyHeaders: "",
securityHeadersEnabled: true,
securityHeaders: JSON.stringify(DEFAULT_SECURITY_HEADERS),
gtmContainerId: "",
Expand Down Expand Up @@ -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);
Expand All @@ -619,7 +624,8 @@ function renderSeoHead(route) {
`<meta data-storefront-seo property="og:title" content="${escapeAttribute(route.opengraphTitle || title)}" />`,
`<meta data-storefront-seo property="og:description" content="${escapeAttribute(route.opengraphDescription || description)}" />`,
canonical ? `<meta data-storefront-seo property="og:url" content="${escapeAttribute(canonical)}" />` : "",
`<meta data-storefront-seo property="og:site_name" content="${escapeAttribute(route.opengraphSiteName || "FunkyCommerce")}" />`,
`<meta data-storefront-seo property="og:site_name" content="${escapeAttribute(siteName)}" />`,
`<meta data-storefront-seo name="apple-mobile-web-app-title" content="${escapeAttribute(siteName)}" />`,
`<meta data-storefront-seo property="og:locale" content="${escapeAttribute(route.lang || defaultLanguage)}" />`,
image ? `<meta data-storefront-seo property="og:image" content="${escapeAttribute(image.url)}" />` : "",
image?.url?.startsWith("https://") ? `<meta data-storefront-seo property="og:image:secure_url" content="${escapeAttribute(image.url)}" />` : "",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1902,7 +1910,7 @@ async function renderRoute(route) {
const heroPreload = heroImage
? `\n <link rel="preload" as="image" href="${escapeAttribute(optimizedHeroImage)}"${heroImageSrcSet} fetchpriority="high" />`
: "";
const seoHead = renderSeoHead(route);
const seoHead = renderSeoHead(route, routeChromeConfig.storeName);
const staticTheme = renderStaticThemeVariables(
routeChromeConfig.colors,
routeChromeConfig.brandPalette,
Expand Down Expand Up @@ -1932,6 +1940,7 @@ async function renderRoute(route) {
`<html lang="${route.lang}"${hasInteractiveStaticChrome ? " data-storefront-flagship" : ""}>`,
)
.replace(/\s*<title(?:\s[^>]*)?>.*?<\/title>/, "")
.replace(/\s*<meta name="apple-mobile-web-app-title" content=".*?" \/>/, "")
.replace(
/\s*<meta name="description" content=".*?" \/>/,
`\n ${seoHead}${heroPreload}${earlyPreloadHints ? `\n ${earlyPreloadHints}` : ""}`,
Expand Down Expand Up @@ -1982,7 +1991,7 @@ async function renderRoute(route) {
rendered = stripBootstrapOverlay(
rendered.replace(
'<div id="root"></div>',
`<div id="root"><div data-prerendered-chrome data-static-header-layout="${staticHeaderLayout}" data-recent-orders-enabled="${routeChromeConfig.recentOrders.enabled ? "true" : "false"}" data-recent-orders-count="${routeChromeConfig.recentOrders.itemCount}" data-recent-orders-interval="${routeChromeConfig.recentOrders.intervalSeconds}" data-recent-orders-quiet="${routeChromeConfig.recentOrders.quietSeconds}" data-recent-orders-new-tab="${routeChromeConfig.recentOrders.openLinksInNewTab ? "true" : "false"}">${staticChrome}<main id="prerendered-storefront" aria-label="Storefront content" data-prerender-activation="${prerenderActivationMode}">${staticBreadcrumbs}<section aria-label="${escapeAttribute(route.title)} content" data-cms-page${generatedRouteSnapshot ? " data-prerendered-cms-snapshot" : ""}><div class="wp-site-blocks entry-content is-layout-flow grid gap-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">${routeSnapshot}</div></section></main>${staticFooter}${renderStaticFloatingControls(route)}</div></div>`,
`<div id="root"><div data-prerendered-chrome data-static-header-layout="${staticHeaderLayout}" data-recent-orders-enabled="${routeChromeConfig.recentOrders.enabled ? "true" : "false"}" data-recent-orders-count="${routeChromeConfig.recentOrders.itemCount}" data-recent-orders-interval="${routeChromeConfig.recentOrders.intervalSeconds}" data-recent-orders-quiet="${routeChromeConfig.recentOrders.quietSeconds}" data-recent-orders-new-tab="${routeChromeConfig.recentOrders.openLinksInNewTab ? "true" : "false"}">${staticChrome}<main id="prerendered-storefront" aria-label="Storefront content" data-prerender-activation="${prerenderActivationMode}" data-show-code-controls="${routeChromeConfig.headerControls.layout.showCodeControls === false ? "false" : "true"}">${staticBreadcrumbs}<section aria-label="${escapeAttribute(route.title)} content" data-cms-page${generatedRouteSnapshot ? " data-prerendered-cms-snapshot" : ""}><div class="wp-site-blocks entry-content is-layout-flow grid gap-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">${routeSnapshot}</div></section></main>${staticFooter}${renderStaticFloatingControls(route)}</div></div>`,
),
);
}
Expand Down Expand Up @@ -3485,6 +3494,7 @@ function renderRedirects(appleMerchantFileEnabled) {
...opaqueMediaProxy,
...mediaDocumentProxy,
...rewriteRules,
...staticGenerationConfig.netlifyRedirects.split("\n").filter((line) => line.trim()),
"/* /index.html 200",
"",
].join("\n");
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -3782,7 +3793,7 @@ await writeFile(
const sitemapXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...sitemapRoutes.map(({ path }) => ` <url><loc>${escapeAttribute(`${sitemapOrigin}${path === "/" ? "" : path}`)}</loc></url>`),
...sitemapRoutes.map(({ path }) => ` <url><loc>${escapeAttribute(sitemapUrl(sitemapOrigin, path))}</loc></url>`),
"</urlset>",
"",
].join("\n");
Expand Down
2 changes: 1 addition & 1 deletion apps/storefront/scripts/route-paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() || "",
Expand Down
4 changes: 4 additions & 0 deletions apps/storefront/scripts/seo-metadata.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<meta data-storefront-seo name="apple-mobile-web-app-title" content="\$\{escapeAttribute\(siteName\)\}"/);
assert.match(prerender, /\.replace\(\/\\s\*<meta name="apple-mobile-web-app-title"/);
assert.match(prerender, /Cache-Control", "public, max-age=0, must-revalidate"/);
});

Expand Down
2 changes: 1 addition & 1 deletion apps/storefront/scripts/shortcode-rendering.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ test("hero CTAs support validated target and rel attributes", async () => {
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\}/);
});
Expand Down
12 changes: 12 additions & 0 deletions apps/storefront/scripts/sitemap-url.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 17 additions & 0 deletions apps/storefront/scripts/sitemap-url.test.mjs
Original file line number Diff line number Diff line change
@@ -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/",
);
});
2 changes: 1 addition & 1 deletion apps/storefront/src/components/wordpressShortcodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading