From 7dee28ec974c1b31a7b6ad1d326bb6526c015c4b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:56:25 +0000 Subject: [PATCH] chore: sync storefront from monorepo Source: coded-letter/coded-letter-monorepo@17a788e7411069a3795de3a02f6aef2f06a8a81b --- apps/storefront/README.md | 15 ++-- .../scripts/cms-tailwind-content.mjs | 28 +++---- .../scripts/cms-tailwind-content.test.mjs | 82 ++++++------------- .../storefront/scripts/cms-tailwind-index.mjs | 56 +++++++++---- .../scripts/cms-tailwind-index.test.mjs | 63 ++++++++++++++ .../scripts/generate-cms-tailwind-content.mjs | 13 ++- .../generate-cms-tailwind-content.test.mjs | 2 +- 7 files changed, 156 insertions(+), 103 deletions(-) diff --git a/apps/storefront/README.md b/apps/storefront/README.md index 4276c78..5b9b43b 100644 --- a/apps/storefront/README.md +++ b/apps/storefront/README.md @@ -16,23 +16,22 @@ Default URL: Configure either the signed source API (`CMS_TAILWIND_SOURCE_API_URL`, `STOREFRONT_ARTIFACT_SIGNING_SECRET`, and `VITE_SITE_URL`) or `VITE_CMS_TAILWIND_MANIFEST_URL` in `apps/storefront/.env.local`. Then run this from -the repository root: +`workspace/frontend`: ```bash pnpm dev:cms ``` -The command requires a valid CMS source or manifest and finishes extraction before -starting Vite. It fails instead of silently starting with only the local stable -contract. Development extraction writes its incremental index under the ignored -`.tailwind/` directory and does not replace the deployment index in `public/`. +The command requires a valid CMS source or manifest and completes extraction before +starting Vite. It fails rather than silently starting with only the stable contract. +Development mode keeps its incremental index under the ignored `.tailwind/` directory, +so it does not overwrite the deployment index in `public/`. After saving CMS content, run `pnpm --filter @funky/storefront audit:cms-tailwind` from a second terminal. Tailwind/Vite watch the generated `apps/storefront/.tailwind/cms-content.html` file and refresh compiled CSS when it -changes. This is manual refresh, not CMS polling; rerun the extraction when content -changes, and restart Vite if it does not detect a generated-file change. Do not use -`--contract-only` for this workflow. +changes. This is a manual refresh, not CMS polling; rerun extraction after content +changes, and restart Vite if it does not detect a generated-file change. ## Static production routes diff --git a/apps/storefront/scripts/cms-tailwind-content.mjs b/apps/storefront/scripts/cms-tailwind-content.mjs index 880cdb9..8818fc7 100644 --- a/apps/storefront/scripts/cms-tailwind-content.mjs +++ b/apps/storefront/scripts/cms-tailwind-content.mjs @@ -86,8 +86,6 @@ const ARBITRARY_PATTERNS = [ /^aspect-\[\d{1,4}\/\d{1,4}\]$/, ]; const DEV_ARBITRARY_PATTERNS = [ - /^(?:from|to|via)-\[#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\](?:\/(?:100|\d{1,2}))?$/, - /^(?:bg|border|caret|decoration|fill|placeholder|ring|stroke|text)-\[#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\](?:\/(?:100|\d{1,2}))?$/, /^(?:blur|backdrop-blur)-\[\d+(?:\.\d+)?(?:px|rem|em)\]$/, /^scale(?:-[xy])?-\[(?:\d+(?:\.\d+)?|\.\d+)\]$/, /^translate-[xy]-\[-?\d+(?:\.\d+)?(?:px|rem|em|%)\]$/, @@ -256,11 +254,8 @@ export function evaluateCmsClassToken(token, { allowNonStableUtilities = false } if (!SIMPLE_UTILITY.test(base)) { return { status: "rejected", reason: "token is not a well-formed Tailwind utility" }; } - if (!STABLE_UTILITY_SET.has(token)) { - if (allowNonStableUtilities) return { status: "accepted" }; - return { status: "dynamic" }; - } - return { status: "accepted" }; + if (!STABLE_UTILITY_SET.has(token) && allowNonStableUtilities) return { status: "dynamic" }; + return STABLE_UTILITY_SET.has(token) ? { status: "accepted" } : { status: "dynamic" }; } function decodeClassAttribute(value) { @@ -304,16 +299,16 @@ export function extractStoredCmsClassTokens(documents) { return tokens; } -export function collectCmsTailwindClasses( - documents, - options = {}, -) { - return collectCmsTailwindClassesFromTokens(extractStoredCmsClassTokens(documents), options); +export function collectCmsTailwindClasses(documents, options) { + return collectCmsTailwindClassesFromTokens( + extractStoredCmsClassTokens(documents), + options, + ); } export function collectCmsTailwindClassesFromTokens( tokens, - { allowNonStableUtilities = false, includeDynamic = true } = {}, + { allowNonStableUtilities = false } = {}, ) { const classes = new Set(CMS_TAILWIND_STABLE_UTILITIES); const dynamic = new Set(); @@ -321,11 +316,8 @@ export function collectCmsTailwindClassesFromTokens( for (const token of tokens) { const evaluation = evaluateCmsClassToken(token, { allowNonStableUtilities }); - if (evaluation.status === "accepted") classes.add(token); - if (evaluation.status === "dynamic") { - dynamic.add(token); - if (includeDynamic) classes.add(token); - } + if (evaluation.status === "accepted" || evaluation.status === "dynamic") classes.add(token); + if (evaluation.status === "dynamic") dynamic.add(token); if (evaluation.status === "rejected") rejected.set(token, evaluation.reason); if (classes.size > MAX_CLASS_COUNT) { throw new Error(`CMS Tailwind class limit exceeded (${MAX_CLASS_COUNT}). Reduce the utility set before rebuilding.`); diff --git a/apps/storefront/scripts/cms-tailwind-content.test.mjs b/apps/storefront/scripts/cms-tailwind-content.test.mjs index dd78035..9aa7a10 100644 --- a/apps/storefront/scripts/cms-tailwind-content.test.mjs +++ b/apps/storefront/scripts/cms-tailwind-content.test.mjs @@ -87,62 +87,6 @@ test("allows only arbitrary values that the route CSS compiler supports", () => assert.equal(evaluateCmsClassToken("bg-[url(https://example.com/image.png)]").status, "rejected"); }); -test("dev extraction additionally compiles approved CMS effects while rejecting unsafe URLs", async () => { - const fixture = '
'; - const standard = collectCmsTailwindClasses([fixture]); - const development = collectCmsTailwindClasses([fixture], { - allowNonStableUtilities: true, - includeDynamic: true, - }); - const result = await postcss([ - tailwindcss({ - content: [{ raw: buildTailwindContentSource(development.classes), extension: "html" }], - darkMode: "class", - }), - ]).process("@tailwind utilities;", { from: undefined }); - - assert.ok(standard.classes.includes("md:text-5xl")); - assert.ok(development.classes.includes("md:text-5xl")); - assert.ok(!standard.classes.includes("shadow-[0_0_12px_rgba(237,34,93,0.35)]")); - assert.ok(development.classes.includes("shadow-[0_0_12px_rgba(237,34,93,0.35)]")); - assert.ok(!development.classes.includes("bg-[url(https://example.com/image.png)]")); - assert.match(result.css, /\.md\\:text-5xl/); - assert.match(result.css, /\.shadow-/); - assert.doesNotMatch(result.css, /example\.com/); -}); - -test("dev extraction compiles common safe CMS effects and important sizing utilities", async () => { - const fixture = ` - - `; - const development = collectCmsTailwindClasses([fixture], { - allowNonStableUtilities: true, - includeDynamic: true, - }); - const result = await postcss([ - tailwindcss({ - content: [{ raw: buildTailwindContentSource(development.classes), extension: "html" }], - darkMode: "class", - }), - ]).process("@tailwind utilities;", { from: undefined }); - - assert.deepEqual(development.rejected, []); - assert.match(result.css, /\.hover\\:shadow-/); - assert.match(result.css, /\.blur-\\\[140px\\\]/); - assert.match(result.css, /\.hover\\:scale-/); - assert.match(result.css, /\.group-hover\\\:-translate-y-0\\.5/); - assert.match(result.css, /\.dark\\:from-/); - assert.match(result.css, /\.\\!max-h-/); - assert.match(result.css, /\.md\\:grid-cols-/); - assert.match(result.css, /\.tracking-/); - assert.match(result.css, /\.group-hover\\:text-/); -}); - test("compiles CMS-authored gradients with arbitrary hexadecimal stops", async () => { const fixture = ` @@ -165,3 +109,29 @@ test("compiles CMS-authored gradients with arbitrary hexadecimal stops", async ( assert.match(result.css, /\.from-\\\[\\#7C3AED\\\]/); assert.match(result.css, /\.to-\\\[\\#66E0FF\\\]/); }); + +test("development extraction adds bounded CMS effects and keeps unsafe URLs excluded", async () => { + const fixture = ` + + `; + const stable = collectCmsTailwindClasses([fixture]); + const development = collectCmsTailwindClasses([fixture], { allowNonStableUtilities: true }); + const result = await postcss([ + tailwindcss({ + content: [{ raw: buildTailwindContentSource(development.classes), extension: "html" }], + darkMode: "class", + }), + ]).process("@tailwind utilities;", { from: undefined }); + + assert.ok(!stable.classes.includes("shadow-[0_0_25px_rgba(0,255,200,0.45)]")); + assert.ok(development.classes.includes("shadow-[0_0_25px_rgba(0,255,200,0.45)]")); + assert.ok(development.classes.includes("blur-[140px]")); + assert.ok(development.classes.includes("!max-h-[75px]")); + assert.ok(!development.classes.includes("bg-[url(https://example.com/image.png)]")); + assert.match(result.css, /\.shadow-/); + assert.match(result.css, /\.blur-\\\[140px\\\]/); + assert.match(result.css, /\.group-hover\\\:-translate-y-0\\.5/); + assert.doesNotMatch(result.css, /example\.com/); +}); diff --git a/apps/storefront/scripts/cms-tailwind-index.mjs b/apps/storefront/scripts/cms-tailwind-index.mjs index 82735c0..6c035b4 100644 --- a/apps/storefront/scripts/cms-tailwind-index.mjs +++ b/apps/storefront/scripts/cms-tailwind-index.mjs @@ -17,6 +17,7 @@ const MAX_DYNAMIC_CLASSES = 10_000; const INVENTORY_PAGE_SIZE = 100; const SOURCE_BATCH_SIZE = 10; const EXTRACTION_REVISION = 2; +const DEVELOPMENT_EXTRACTION_REVISION = 3; const REQUEST_TIMEOUT_MS = 60_000; const TRANSIENT_RETRY_DELAY_MS = 500; const REQUEST_COOLDOWN_MS = 250; @@ -223,7 +224,7 @@ function validateSourceBatch(payload, expectedSources) { }); } -function sourceDynamicClasses(source) { +function sourceDynamicClasses(source, { includeCmsUtilities = false } = {}) { const tokens = extractStoredCmsClassTokens([ source.content, source.excerpt, @@ -232,7 +233,7 @@ function sourceDynamicClasses(source) { const dynamic = new Set(); const rejected = []; for (const token of tokens) { - const result = evaluateCmsClassToken(token); + const result = evaluateCmsClassToken(token, { allowNonStableUtilities: includeCmsUtilities }); if (result.status === "dynamic") dynamic.add(token); if (result.status === "rejected") rejected.push({ token, reason: result.reason }); } @@ -247,6 +248,8 @@ function sourceDynamicClasses(source) { export async function fetchChangedTailwindSources({ changedSources, + includeCmsUtilities = false, + extractionRevision = EXTRACTION_REVISION, sleepImpl = sleep, ...requestOptions }) { @@ -265,11 +268,11 @@ export async function fetchChangedTailwindSources({ }); requestCount += 1; for (const source of validateSourceBatch(payload, batch)) { - const extracted = sourceDynamicClasses(source); + const extracted = sourceDynamicClasses(source, { includeCmsUtilities }); records.set(source.key, { version: source.version, classes: extracted.classes, - extractionRevision: EXTRACTION_REVISION, + extractionRevision, }); rejected.push(...extracted.rejected.map((entry) => ({ ...entry, source: source.key }))); } @@ -292,7 +295,7 @@ function emptyIndex() { }; } -async function readLocalBootstrapIndex(indexOutputPath) { +async function readLocalBootstrapIndex(indexOutputPath, options) { let payload; try { payload = JSON.parse(await readFile(indexOutputPath, "utf8")); @@ -302,17 +305,17 @@ async function readLocalBootstrapIndex(indexOutputPath) { `Local Tailwind bootstrap index could not be read: ${error instanceof Error ? error.message : String(error)}`, ); } - return validateTailwindSourceIndex(payload); + return validateTailwindSourceIndex(payload, options); } -export function validateTailwindSourceIndex(payload) { +export function validateTailwindSourceIndex(payload, { includeCmsUtilities = false } = {}) { if ( !payload || payload.schemaVersion !== 1 || (payload.extractionRevision !== undefined && ( !Number.isInteger(payload.extractionRevision) || payload.extractionRevision < 1 - || payload.extractionRevision > EXTRACTION_REVISION + || payload.extractionRevision > DEVELOPMENT_EXTRACTION_REVISION )) || typeof payload.generatedAt !== "string" || !Number.isFinite(Date.parse(payload.generatedAt)) @@ -341,7 +344,7 @@ export function validateTailwindSourceIndex(payload) { && ( !Number.isInteger(source.extractionRevision) || source.extractionRevision < 1 - || source.extractionRevision > EXTRACTION_REVISION + || source.extractionRevision > DEVELOPMENT_EXTRACTION_REVISION ) ) ) { @@ -352,7 +355,8 @@ export function validateTailwindSourceIndex(payload) { if ( classes.length !== source.classes.length || classes.some((token, index) => - token !== source.classes[index] || evaluateCmsClassToken(token).status !== "dynamic") + token !== source.classes[index] + || evaluateCmsClassToken(token, { allowNonStableUtilities: includeCmsUtilities }).status !== "dynamic") ) { throw new Error(`Previous Tailwind source index contains invalid classes for ${key}.`); } @@ -378,6 +382,7 @@ export async function fetchPreviousTailwindSourceIndex({ indexUrl, fetchImpl = fetch, allowBootstrap = false, + includeCmsUtilities = false, }) { const url = validateHttpsUrl(indexUrl, "Tailwind source index URL"); const response = await fetchImpl(url.href, { @@ -410,14 +415,15 @@ export async function fetchPreviousTailwindSourceIndex({ } catch { throw new Error("previous Tailwind source index returned invalid JSON"); } - return validateTailwindSourceIndex(payload); + return validateTailwindSourceIndex(payload, { includeCmsUtilities }); } return validateTailwindSourceIndex( await readBoundedJsonResponse(response, "previous Tailwind source index", MAX_INDEX_BYTES), + { includeCmsUtilities }, ); } -function createIndex(sources, generatedAt) { +function createIndex(sources, generatedAt, extractionRevision) { const normalizedSources = {}; const dynamic = new Set(); for (const key of [...sources.keys()].sort()) { @@ -431,7 +437,7 @@ function createIndex(sources, generatedAt) { } return { schemaVersion: 1, - extractionRevision: EXTRACTION_REVISION, + extractionRevision, generatedAt: new Date(generatedAt).toISOString(), sourceCount: sources.size, classCount: classes.length, @@ -451,12 +457,25 @@ export async function buildIncrementalTailwindIndex({ sleepImpl = sleep, generatedAt = Date.now(), allowBootstrap = false, + includeCmsUtilities = false, + preferLocalIndex = false, }) { if (!siteUrl) throw new Error("VITE_SITE_URL is required for incremental CMS Tailwind extraction."); const requestOptions = { apiUrl, signingSecret, fetchImpl, sleepImpl }; - let previous = await fetchPreviousTailwindSourceIndex({ indexUrl, fetchImpl, allowBootstrap }); + const extractionRevision = includeCmsUtilities + ? DEVELOPMENT_EXTRACTION_REVISION + : EXTRACTION_REVISION; + let previous = preferLocalIndex + ? await readLocalBootstrapIndex(indexOutputPath, { includeCmsUtilities }) + : null; + previous ||= await fetchPreviousTailwindSourceIndex({ + indexUrl, + fetchImpl, + allowBootstrap, + includeCmsUtilities, + }); if (allowBootstrap && previous.generatedAt === "1970-01-01T00:00:00.000Z") { - previous = await readLocalBootstrapIndex(indexOutputPath) || previous; + previous = await readLocalBootstrapIndex(indexOutputPath, { includeCmsUtilities }) || previous; } const inventoryResult = await fetchTailwindSourceInventory(requestOptions); const inventoryByKey = new Map(inventoryResult.sources.map((source) => [source.key, source])); @@ -464,11 +483,13 @@ export async function buildIncrementalTailwindIndex({ (source) => { const previousSource = previous.sources[source.key]; return previousSource?.version !== source.version - || previousSource.extractionRevision !== EXTRACTION_REVISION; + || previousSource.extractionRevision !== extractionRevision; }, ); const changedResult = await fetchChangedTailwindSources({ changedSources: changed, + includeCmsUtilities, + extractionRevision, ...requestOptions, }); @@ -481,13 +502,14 @@ export async function buildIncrementalTailwindIndex({ nextSources.set(source.key, record); } - const index = createIndex(nextSources, generatedAt); + const index = createIndex(nextSources, generatedAt, extractionRevision); const indexJson = `${JSON.stringify(index)}\n`; if (Buffer.byteLength(indexJson) > MAX_INDEX_BYTES) { throw new Error("CMS Tailwind source index exceeded the 2 MiB limit."); } const { classes, dynamic } = collectCmsTailwindClassesFromTokens( Object.values(index.sources).flatMap((source) => source.classes), + { allowNonStableUtilities: includeCmsUtilities }, ); await mkdir(resolve(outputPath, ".."), { recursive: true }); await mkdir(resolve(indexOutputPath, ".."), { recursive: true }); diff --git a/apps/storefront/scripts/cms-tailwind-index.test.mjs b/apps/storefront/scripts/cms-tailwind-index.test.mjs index 8dbf366..8782775 100644 --- a/apps/storefront/scripts/cms-tailwind-index.test.mjs +++ b/apps/storefront/scripts/cms-tailwind-index.test.mjs @@ -130,6 +130,69 @@ test("bootstrap fetches bounded source batches and publishes dynamic classes", a }); }); +test("development index compiles safe CMS effects into its private local index", async () => { + await withOutput(async ({ outputPath }) => { + const indexOutputPath = join(dirname(outputPath), "cms-index.json"); + const sourceVersion = version("page-7-dev"); + const fetchImpl = async (url, options = {}) => { + if (url === `${siteUrl}/.well-known/funkycommerce-tailwind-index.json`) { + return new Response("", { status: 404 }); + } + if (url.endsWith("/inventory")) { + return jsonResponse({ + schemaVersion: 1, + cursor: 0, + nextCursor: 7, + hasMore: false, + sources: [{ key: "page:7", id: 7, type: "page", version: sourceVersion }], + }); + } + assert.deepEqual(JSON.parse(options.body).ids, [7]); + return jsonResponse({ + schemaVersion: 1, + sources: [ + source( + 7, + "page", + sourceVersion, + '', + ), + ], + }); + }; + + const result = await buildIncrementalTailwindIndex({ + apiUrl, + signingSecret, + siteUrl, + outputPath, + indexOutputPath, + fetchImpl, + generatedAt: Date.parse("2026-09-24T13:00:00.000Z"), + allowBootstrap: true, + includeCmsUtilities: true, + }); + + assert.ok(result.classes.includes("shadow-[0_0_25px_rgba(0,255,200,0.45)]")); + assert.ok(result.classes.includes("hover:scale-[1.03]")); + assert.ok(!result.classes.includes("bg-[url(https://example.com/image.png)]")); + const localIndex = JSON.parse(await readFile(indexOutputPath, "utf8")); + assert.deepEqual( + localIndex.sources["page:7"].classes, + ["hover:scale-[1.03]", "shadow-[0_0_25px_rgba(0,255,200,0.45)]"], + ); + const compiled = await postcss([ + tailwindcss({ + content: [{ raw: await readFile(outputPath, "utf8"), extension: "html" }], + darkMode: "class", + }), + ]).process("@tailwind utilities;", { from: undefined }); + assert.match(compiled.css, /\.shadow-/); + assert.match(compiled.css, /\.hover\\:scale-/); + assert.doesNotMatch(compiled.css, /example\.com/); + }); +}); + test("missing deployed index requires the explicit bootstrap flag", async () => { await withOutput(async ({ outputPath, indexOutputPath }) => { await assert.rejects( diff --git a/apps/storefront/scripts/generate-cms-tailwind-content.mjs b/apps/storefront/scripts/generate-cms-tailwind-content.mjs index 3487145..e0fd50b 100644 --- a/apps/storefront/scripts/generate-cms-tailwind-content.mjs +++ b/apps/storefront/scripts/generate-cms-tailwind-content.mjs @@ -13,7 +13,10 @@ import { buildIncrementalTailwindIndex } from "./cms-tailwind-index.mjs"; const MAX_MANIFEST_BYTES = 1024 * 1024; const MAX_MANIFEST_CLASSES = 10_000; const MANIFEST_TIMEOUT_MS = 5_000; -const viteEnvironment = loadEnv("production", process.cwd(), ""); +const viteEnvironment = { + ...loadEnv("production", resolve(process.cwd(), "../.."), ""), + ...loadEnv("production", process.cwd(), ""), +}; function validateManifestUrl(value) { const manifestUrl = new URL(value); @@ -113,7 +116,10 @@ export async function generateCmsTailwindContent({ process.env.CMS_TAILWIND_SOURCE_API_URL || viteEnvironment.CMS_TAILWIND_SOURCE_API_URL )?.trim(), - signingSecret = process.env.STOREFRONT_ARTIFACT_SIGNING_SECRET?.trim(), + signingSecret = ( + process.env.STOREFRONT_ARTIFACT_SIGNING_SECRET + || viteEnvironment.STOREFRONT_ARTIFACT_SIGNING_SECRET + )?.trim(), siteUrl = ( process.env.VITE_SITE_URL || viteEnvironment.VITE_SITE_URL @@ -153,6 +159,8 @@ export async function generateCmsTailwindContent({ indexOutputPath, fetchImpl, allowBootstrap, + includeCmsUtilities, + preferLocalIndex: devMode, }); for (const { source, token, reason } of result.rejected) { console.warn(`[cms-tailwind] ignored "${token}" from ${source}: ${reason}.`); @@ -183,7 +191,6 @@ export async function generateCmsTailwindContent({ const { classes, dynamic, rejected } = collectCmsTailwindClassesFromTokens(manifest?.classes || [], { allowNonStableUtilities: includeCmsUtilities, - includeDynamic: true, }); for (const { token, reason } of rejected) { console.warn(`[cms-tailwind] ignored manifest class "${token}": ${reason}.`); diff --git a/apps/storefront/scripts/generate-cms-tailwind-content.test.mjs b/apps/storefront/scripts/generate-cms-tailwind-content.test.mjs index 342339e..a078f3b 100644 --- a/apps/storefront/scripts/generate-cms-tailwind-content.test.mjs +++ b/apps/storefront/scripts/generate-cms-tailwind-content.test.mjs @@ -84,7 +84,7 @@ test("one static manifest GET adds CMS-specific utilities to Tailwind content", } }); -test("required extraction fails when no CMS source or manifest is configured", async () => { +test("required development extraction fails when no CMS source is configured", async () => { await assert.rejects( generateCmsTailwindContent({ sourceApiUrl: "",