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
15 changes: 7 additions & 8 deletions apps/storefront/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 10 additions & 18 deletions apps/storefront/scripts/cms-tailwind-content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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|%)\]$/,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -304,28 +299,25 @@ 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();
const rejected = new Map();

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.`);
Expand Down
82 changes: 26 additions & 56 deletions apps/storefront/scripts/cms-tailwind-content.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div class="md:text-5xl shadow-[0_0_12px_rgba(237,34,93,0.35)] bg-[url(https://example.com/image.png)]"></div>';
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 = `
<div class="hover:shadow-[0_0_25px_rgba(0,255,200,0.45)] blur-[140px]
hover:scale-[1.03] group-hover:-translate-y-0.5
bg-[length:200%_200%] dark:from-[#101010]/85
group-hover:translate-y-[-4px] animate-[floatLight_8s_ease-in-out_infinite]
!max-h-[75px] rounded-[calc(1.5rem-2px)] md:grid-cols-[70%_30%]
tracking-[0.25em] group-hover:text-[#90b74b]"></div>
`;
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 = `
<div class="h-2 rounded-full bg-gradient-to-r from-[#7C3AED] to-[#66E0FF]"></div>
Expand All @@ -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 = `
<div class="shadow-[0_0_25px_rgba(0,255,200,0.45)] blur-[140px]
hover:scale-[1.03] group-hover:-translate-y-0.5 !max-h-[75px]
bg-[url(https://example.com/image.png)]"></div>
`;
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/);
});
56 changes: 39 additions & 17 deletions apps/storefront/scripts/cms-tailwind-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -223,7 +224,7 @@ function validateSourceBatch(payload, expectedSources) {
});
}

function sourceDynamicClasses(source) {
function sourceDynamicClasses(source, { includeCmsUtilities = false } = {}) {
const tokens = extractStoredCmsClassTokens([
source.content,
source.excerpt,
Expand All @@ -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 });
}
Expand All @@ -247,6 +248,8 @@ function sourceDynamicClasses(source) {

export async function fetchChangedTailwindSources({
changedSources,
includeCmsUtilities = false,
extractionRevision = EXTRACTION_REVISION,
sleepImpl = sleep,
...requestOptions
}) {
Expand All @@ -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 })));
}
Expand All @@ -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"));
Expand All @@ -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))
Expand Down Expand Up @@ -341,7 +344,7 @@ export function validateTailwindSourceIndex(payload) {
&& (
!Number.isInteger(source.extractionRevision)
|| source.extractionRevision < 1
|| source.extractionRevision > EXTRACTION_REVISION
|| source.extractionRevision > DEVELOPMENT_EXTRACTION_REVISION
)
)
) {
Expand All @@ -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}.`);
}
Expand All @@ -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, {
Expand Down Expand Up @@ -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()) {
Expand All @@ -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,
Expand All @@ -451,24 +457,39 @@ 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]));
const changed = inventoryResult.sources.filter(
(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,
});

Expand All @@ -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 });
Expand Down
Loading
Loading