From ee25bc3e53d7c75b0095aa116774b12950ba5350 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 11 Oct 2026 18:06:02 +0530 Subject: [PATCH] perf: rolepatch home critical CSS + deferred scripts for LCP Co-Authored-By: Claude Sonnet 5.5 --- landing-astro/astro.config.mjs | 4 +- landing-astro/package.json | 2 +- landing-astro/scripts/inline-critical-css.mjs | 334 ++++++++++++++++++ .../src/components/DeferredScripts.astro | 60 ++++ landing-astro/src/pages/index.astro | 4 +- scripts/run-inline-critical-css.mjs | 7 +- 6 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 landing-astro/scripts/inline-critical-css.mjs create mode 100644 landing-astro/src/components/DeferredScripts.astro diff --git a/landing-astro/astro.config.mjs b/landing-astro/astro.config.mjs index 19c2326..78838de 100644 --- a/landing-astro/astro.config.mjs +++ b/landing-astro/astro.config.mjs @@ -7,8 +7,8 @@ import { readFile, writeFile } from 'node:fs/promises'; // Mirrors fleet/sarthakagrawal/astro.config.mjs, the reference 360 ms-LCP // Astro setup. Pure static output (no SSR adapter) — the rolepatch.com -// landing is fully static markup. Inline styles to avoid a render-blocking -// request after the document; the overlay copies /_astro assets alongside it. +// landing is fully static markup. The postbuild critical-CSS pass splits the +// inlined home styles; the overlay copies /_astro assets alongside it. // // Tailwind v4 via the official Vite plugin (fleet web stack standard, // VoidZero ecosystem). Lightning CSS replaces the default PostCSS diff --git a/landing-astro/package.json b/landing-astro/package.json index 4c03c57..eab5b1a 100644 --- a/landing-astro/package.json +++ b/landing-astro/package.json @@ -6,7 +6,7 @@ "description": "Static Astro port of the rolepatch.com landing page. Deploys to Cloudflare Pages and (post-cutover) serves the `/` route in front of the Next.js Worker.", "scripts": { "dev": "astro dev", - "build": "astro build", + "build": "astro build && node ../scripts/run-inline-critical-css.mjs --astro", "preview": "astro preview", "astro": "astro", "deploy": "wrangler pages deploy dist/ --project-name resume-tailor-landing --commit-dirty=true" diff --git a/landing-astro/scripts/inline-critical-css.mjs b/landing-astro/scripts/inline-critical-css.mjs new file mode 100644 index 0000000..1d9985f --- /dev/null +++ b/landing-astro/scripts/inline-critical-css.mjs @@ -0,0 +1,334 @@ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { transform } from 'lightningcss'; +import { parseHTML } from 'linkedom'; + +// Home alone uses the Workbench bundle. Keep static-page styles (including layout +// below the fold, so an early scroll never exposes unstyled content), while the +// library's other blocks, themes and dynamic states load asynchronously. +export async function runAstroInlineCss() { + const file = new URL('../dist/index.html', import.meta.url); + const source = await readFile(file, 'utf8'); + const styles = /]*>([\s\S]*?)<\/style>/g; + // Use the same font policy in both CSS stages: a late font download must not + // rewrap text when the deferred stylesheet becomes active. + const css = transform({ + filename: 'home.css', + minify: true, + code: Buffer.from( + [...source.matchAll(styles)] + .map((match) => match[1]) + .join('\n') + .replaceAll('font-display:swap', 'font-display:optional') + ), + }).code.toString(); + // Restrict first paint to the header and complete hero at every breakpoint. + let html = source.replace(styles, ''); + const boundary = html.search(/]*\bid="proof"/); + if (boundary < 0) throw new Error('Expected the Workbench proof section'); + const initial = html.slice(0, boundary); + // Match the browser's decoded class tokens, not serialized HTML. In particular, + // max-sm:[&>*]:w-full is emitted as max-sm:[&>*]:w-full. Missing that + // rule paints the CTAs in one row, then adds 60px when deferred CSS stacks them. + const classNames = (markup) => new Set( + [...parseHTML(markup).document.querySelectorAll('[class]')].flatMap((element) => + [...element.classList] + ) + ); + const allClasses = classNames(html); + const classes = classNames(initial); + const types = new Set([...initial.matchAll(/<([\w-]+)/g)].map((match) => match[1])); + + function matches(selector, aboveFold = true) { + return selector.every((part) => { + if (part.type === 'class') return (aboveFold ? classes : allClasses).has(part.name); + if (part.type === 'type') return !aboveFold || types.has(part.name); + if (part.type === 'id') return (aboveFold ? initial : html).includes(`id="${part.name}"`); + if (part.type === 'attribute' && part.name.startsWith('data-')) { + // This static page has no theme-switch bootstrap; keep its shipped mode. + return part.operation?.operator === 'equal' + ? html.includes(`${part.name}="${part.operation.value}"`) + : html.includes(part.name); + } + if (part.type === 'pseudo-class' && ['where', 'is'].includes(part.kind)) { + return part.selectors.some((nested) => matches(nested, aboveFold)); + } + return true; + }); + } + + // Keep below-fold geometry, then reveal its decoration when the full CSS is ready. + const layoutProperty = + /^(display|position|inset|top|right|bottom|left|float|clear|box-sizing|width|height|min-|max-|margin|padding|gap|row-gap|column-gap|grid|flex|align|justify|order|font|line-height|letter-spacing|text-(align|wrap|transform|indent|overflow)|white-space|word|overflow|border(?!.*(?:color|radius))|aspect-ratio|columns|column-width|column-count|list-style|vertical-align|writing-mode)/; + function isVariable(declaration) { + // Lightning CSS also represents unsupported standard properties (text-wrap, + // font-optical-sizing, font-synthesis, etc.) as `custom` declarations. + return declaration.property === 'custom' && declaration.value.name.startsWith('--'); + } + + function keepLayout(declaration) { + const property = + declaration.property === 'unparsed' + ? declaration.value.propertyId.property + : declaration.property === 'custom' + ? declaration.value.name + : declaration.property; + return isVariable(declaration) || layoutProperty.test(property); + } + + const faces = new Set(); + const faceKey = (rule) => JSON.stringify(rule.value.properties, (key, value) => + key === 'loc' ? undefined : value + ); + const ruleContexts = []; + let layoutOnly = false; + const selected = transform({ + filename: 'home.css', + code: Buffer.from(css), + minify: true, + visitor: { + Rule(rule) { + if ( + rule.type === 'style' && + !rule.value.selectors.some((selector) => matches(selector, false)) + ) + return []; + if (rule.type === 'font-face') { + const face = faceKey(rule); + if (!face.includes('Geist') || faces.has(face)) return []; + faces.add(face); + } + ruleContexts.push(layoutOnly); + layoutOnly = + rule.type === 'style' && !rule.value.selectors.some((selector) => matches(selector)); + }, + RuleExit() { + layoutOnly = ruleContexts.pop(); + }, + Declaration(declaration) { + if (layoutOnly && !keepLayout(declaration)) return []; + }, + }, + }).code.toString(); + + // Follow variable dependencies rather than inlining every library token. + const variables = new Map(); + const referenced = new Set(); + transform({ + filename: 'home.css', + code: Buffer.from(selected), + visitor: { + Declaration(declaration) { + const value = JSON.stringify(declaration.value); + const names = [...value.matchAll(/--[\w-]+/g)].map((match) => match[0]); + if (isVariable(declaration)) { + variables.set(declaration.value.name, [ + ...(variables.get(declaration.value.name) ?? []), + ...names.filter((name) => name !== declaration.value.name), + ]); + } else { + for (const name of names) referenced.add(name); + } + }, + }, + }); + for (const match of html.matchAll(/var\((--[\w-]+)/g)) referenced.add(match[1]); + for (const name of referenced) { + for (const dependency of variables.get(name) ?? []) referenced.add(dependency); + } + const minimal = transform({ + filename: 'home.css', + code: Buffer.from(selected), + minify: true, + visitor: { + Declaration(declaration) { + if (isVariable(declaration) && !referenced.has(declaration.value.name)) return []; + }, + Rule(rule) { + if (rule.type === 'property' && !referenced.has(rule.value.name)) return []; + }, + }, + }).code.toString(); + const critical = transform({ + filename: 'home.css', + code: Buffer.from(minimal), + minify: true, + visitor: { + Rule(rule) { + if (rule.type === 'style' && !rule.value.selectors.some((selector) => matches(selector))) + return []; + }, + }, + }).code.toString(); + + // Independently guard the mobile stacking rule: the declaration-parity audit + // below uses matches() in both stages and cannot detect its own false negatives. + let hasMobileCtaRule = false; + transform({ + filename: 'home.css', + code: Buffer.from(critical), + visitor: { + Rule(rule) { + if (rule.type === 'style' && rule.value.selectors.some((selector) => + selector.some((part) => part.type === 'class' && part.name === 'max-sm:[&>*]:w-full') + )) hasMobileCtaRule = true; + }, + }, + }); + if (!hasMobileCtaRule) throw new Error('Critical CSS is missing the mobile CTA stacking rule'); + + // Register initial fonts only once. Repeating @font-face in a late stylesheet + // creates new FontFace objects with fresh display timelines: even `optional` + // can then select an already-cached Geist and rewrap the painted hero. + const deferred = transform({ + filename: 'home.css', + code: Buffer.from(css), + minify: true, + visitor: { + Rule(rule) { + if (rule.type === 'font-face' && faces.has(faceKey(rule))) return []; + }, + }, + }).code.toString(); + + // Audit the generated page, not just a fixed list of typography properties. + // Expand grouped selectors and retain conditional contexts so every initial + // rule has the same declarations and values in both CSS stages. + function initialDeclarations(code) { + const found = new Map(); + const contexts = []; + transform({ + filename: 'home.css', + code: Buffer.from(code), + visitor: { + Rule(rule) { + const { rules, loc, ...condition } = rule.value; + if (rule.type !== 'style') { + contexts.push(JSON.stringify({ type: rule.type, ...condition })); + return; + } + for (const selector of rule.value.selectors.filter((value) => matches(value))) { + for (const key of ['declarations', 'importantDeclarations']) { + for (const declaration of rule.value.declarations[key]) { + if (isVariable(declaration) && !referenced.has(declaration.value.name)) continue; + const property = + declaration.property === 'custom' + ? declaration.value.name + : declaration.property === 'unparsed' + ? declaration.value.propertyId.property + : declaration.property; + found.set( + JSON.stringify([contexts, selector, key, property]), + JSON.stringify(declaration, (name, value) => (name === 'loc' ? undefined : value)) + ); + } + } + } + }, + RuleExit(rule) { + if (rule.type !== 'style') contexts.pop(); + }, + }, + }); + return found; + } + const initialFull = initialDeclarations(css); + const initialCritical = initialDeclarations(critical); + for (const [key, value] of initialFull) { + if (initialCritical.get(key) !== value) { + throw new Error( + `Critical CSS differs from full CSS for initial declaration ${key}: ${value}` + ); + } + } + // Declaration parity alone misses font registration changes. Keep every + // initial face (including the metric fallback) exactly once in critical CSS + // and ensure activating the deferred sheet cannot replace it. + const initialFaces = new Set(); + transform({ + filename: 'home.css', + code: Buffer.from(critical), + visitor: { + Rule(rule) { + if (rule.type !== 'font-face') return; + const face = faceKey(rule); + if (initialFaces.has(face)) throw new Error('Duplicate critical font face'); + initialFaces.add(face); + }, + }, + }); + for (const face of faces) { + if (!initialFaces.has(face)) throw new Error(`Critical CSS is missing font face ${face}`); + } + transform({ + filename: 'home.css', + code: Buffer.from(deferred), + visitor: { + Rule(rule) { + if (rule.type === 'font-face' && initialFaces.has(faceKey(rule))) { + throw new Error('Deferred CSS re-registers an initial font face'); + } + }, + }, + }); + const layout = transform({ + filename: 'home.css', + code: Buffer.from(minimal), + minify: true, + visitor: { + Rule(rule) { + if (['property', 'font-face', 'keyframes'].includes(rule.type)) return []; + if (rule.type === 'style' && rule.value.selectors.some((selector) => matches(selector))) + return []; + }, + }, + }).code.toString(); + const hash = createHash('sha256').update(deferred).digest('hex').slice(0, 12); + const href = `/_astro/home-rest.${hash}.css`; + await writeFile(new URL(`../dist${href}`, import.meta.url), deferred); + const footerScripts = []; + // Shared Base has no deferral controls. Retain its exact footer handler and + // script attributes, but keep these downloads out of the hero's request window. + html = html.replace(/]*)>([\s\S]*?)<\/script>/g, (tag, attributes, body) => { + const external = /\bsrc="[^"]+"/.test(attributes); + const footer = body.includes('const api = "https://api.sassmaker.com"'); + if (!external && !footer) return tag; + if (footer) { + const footerHash = createHash('sha256').update(body).digest('hex').slice(0, 12); + const footerHref = `/_astro/home-footer.${footerHash}.js`; + footerScripts.push([footerHref, body]); + attributes += ` src="${footerHref}"`; + } + const type = attributes.match(/\btype="([^"]+)"/); + attributes = attributes.replace(/\s*type="[^"]+"/, ''); + return ``; + }); + if (footerScripts.length !== 1) throw new Error('Expected exactly one shared footer handler'); + for (const [path, body] of footerScripts) + await writeFile(new URL(`../dist${path}`, import.meta.url), body); + const cloak = + 'html:not([data-home-css-ready]) main>section:not(:first-child),html:not([data-home-css-ready]) footer{visibility:hidden}'; + const links = ``; + // Use identical responsive attributes on preload and img. The viewport picks + // one candidate for both; no mobile/full-size duplicate download. + const hero = html.match(/]*\bsrc="\/images\/patch-stage\.webp"[^>]*>/)?.[0]; + if (!hero) throw new Error('Expected the responsive Workbench hero'); + const attribute = (name) => hero.match(new RegExp(`\\b${name}="([^"]+)"`, 'i'))?.[1]; + if (!attribute('srcset') || !attribute('sizes')) { + throw new Error('Workbench hero is missing responsive attributes'); + } + const preload = ``; + const result = html + .replace(/(?=]*id="proof")/, ``) + .replace('', `${links}`); + if (Buffer.byteLength(result.split('')[0]) >= 40_000) { + throw new Error( + `Home head exceeds the 40 KB critical-CSS budget: ${Buffer.byteLength(result.split('')[0])} bytes (${critical.length} CSS)` + ); + } + await writeFile(file, result); + console.log( + `[inline-critical-css] Astro home: ${Buffer.byteLength(source)} → ${Buffer.byteLength(result)} bytes; head ${Buffer.byteLength(result.split('')[0])} bytes` + ); +} diff --git a/landing-astro/src/components/DeferredScripts.astro b/landing-astro/src/components/DeferredScripts.astro new file mode 100644 index 0000000..885cc2f --- /dev/null +++ b/landing-astro/src/components/DeferredScripts.astro @@ -0,0 +1,60 @@ + diff --git a/landing-astro/src/pages/index.astro b/landing-astro/src/pages/index.astro index c237791..685a513 100644 --- a/landing-astro/src/pages/index.astro +++ b/landing-astro/src/pages/index.astro @@ -5,6 +5,7 @@ import { baseProps } from '@saas-maker/templates/page'; import { workbenchContent } from '@saas-maker/templates/schema'; import { APP_HEALTH_CLICK_SCRIPT, PRODUCT_FAQS, productJsonLd, REDIRECT_AUTHENTICATED_SCRIPT } from '@/lib/product'; import home from '../content/home.json'; +import DeferredScripts from '../components/DeferredScripts.astro'; const content = workbenchContent.parse(home); const faqs = PRODUCT_FAQS.slice(0, 4); @@ -48,7 +49,7 @@ content.page.jsonLd = [jsonLd, { data-vitals-routes="changelog,docs,faq" > @@ -89,4 +90,5 @@ content.page.jsonLd = [jsonLd, { + diff --git a/scripts/run-inline-critical-css.mjs b/scripts/run-inline-critical-css.mjs index c8e4201..b6ab961 100644 --- a/scripts/run-inline-critical-css.mjs +++ b/scripts/run-inline-critical-css.mjs @@ -1,4 +1,9 @@ #!/usr/bin/env node import { runInlineCss } from './inline-critical-css.mjs'; -await runInlineCss({ strict: process.argv.includes('--strict') }); +if (process.argv.includes('--astro')) { + const { runAstroInlineCss } = await import('../landing-astro/scripts/inline-critical-css.mjs'); + await runAstroInlineCss(); +} else { + await runInlineCss({ strict: process.argv.includes('--strict') }); +}