Skip to content

Instantly share code, notes, and snippets.

@johnfmorton
Created June 8, 2026 15:53
Show Gist options
  • Select an option

  • Save johnfmorton/7d886854f0a93fd4933b454f27851363 to your computer and use it in GitHub Desktop.

Select an option

Save johnfmorton/7d886854f0a93fd4933b454f27851363 to your computer and use it in GitHub Desktop.
Script used to create Critical CSS with Vite and Craft CMS
#!/usr/bin/env node
/**
* Critical CSS Generator using Beasties
*
* This script fetches HTML from configured URLs and extracts critical CSS
* using Beasties (a pure Node.js solution, no browser required).
*
* Usage:
* node scripts/generate-critical-css.mjs
*
* Environment Variables:
* CRITICAL_URL - Base URL to fetch pages from (reads from .env or shell environment)
*/
import 'dotenv/config'
import Beasties from 'beasties'
import { mkdir, writeFile, readFile } from 'fs/promises'
import { dirname, join, resolve } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(__dirname, '..')
// Configuration for pages to generate critical CSS
const pages = [
{ uri: '/', template: 'index' },
{ uri: '/uses', template: '_pages/_page' },
{ uri: '/blog/extracting-a-youtube-id-from-a-url-with-twig', template: '_posts/_entry' },
// Additional pages for templates that manually include critical CSS
{ uri: '/', template: 'standalone' }, // Used by standalone.twig, links, search
]
// Output directory for critical CSS files
const outputDir = join(projectRoot, 'web/dist/criticalcss')
async function fetchHtml(url) {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
}
return response.text()
}
async function getBuiltCssInfo() {
// Read the Vite manifest to find the built CSS file
const manifestPath = join(projectRoot, 'web/dist/.vite/manifest.json')
try {
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'))
const appEntry = manifest['src/js/app.ts']
if (appEntry && appEntry.css && appEntry.css.length > 0) {
const cssFile = appEntry.css[0]
return {
absolutePath: join(projectRoot, 'web/dist', cssFile),
// The href as it appears in the HTML (e.g., /dist/assets/app-xxx.css)
href: `/dist/${cssFile}`,
}
}
} catch (error) {
console.warn('Could not read Vite manifest, looking for CSS files directly')
}
// Fallback: look for any CSS file in the dist directory
const { glob } = await import('glob')
const cssFiles = await glob('web/dist/assets/*.css', { cwd: projectRoot })
if (cssFiles.length > 0) {
const cssFile = cssFiles[0].replace('web/dist/', '')
return {
absolutePath: join(projectRoot, cssFiles[0]),
href: `/dist/${cssFile}`,
}
}
throw new Error('No CSS files found in web/dist/. Run "npm run build" first.')
}
async function generateCriticalCss(baseUrl) {
console.log(`\nGenerating critical CSS from: ${baseUrl}\n`)
// Ensure output directory exists
await mkdir(outputDir, { recursive: true })
// Get the built CSS info
const cssInfo = await getBuiltCssInfo()
const cssContent = await readFile(cssInfo.absolutePath, 'utf-8')
console.log(`Using CSS from: ${cssInfo.absolutePath}`)
console.log(`CSS href: ${cssInfo.href}\n`)
// Initialize Beasties with path configuration
// Beasties will read CSS files from the filesystem using this path
const beasties = new Beasties({
// Base path for resolving CSS file paths
path: join(projectRoot, 'web'),
// Public URL path that maps to the 'path' directory
publicPath: '/',
// Reduce/minify the inlined CSS
reduceInlineStyles: true,
// Don't add preload links for the full stylesheet
preload: 'none',
// Include fonts in critical CSS
fonts: true,
// Log level
logLevel: 'info',
})
const results = []
for (const page of pages) {
const url = `${baseUrl}${page.uri}`
// Preserve directory structure: _pages/_page -> _pages/_page_critical.min.css
const outputFilename = `${page.template}_critical.min.css`
const outputPath = join(outputDir, outputFilename)
try {
console.log(`Processing: ${url} -> ${outputFilename}`)
// Fetch HTML from the page
let html = await fetchHtml(url)
// Remove any existing <style> tags to avoid including them in output
// We'll track them so we don't include them in the critical CSS
const existingStyles = []
html = html.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (match) => {
existingStyles.push(match)
return '<!-- existing-style-placeholder -->'
})
// Ensure HTML has a link to the built CSS file
// Replace any existing CSS links with our known CSS file path
// This ensures Beasties can find and read it from the filesystem
html = html.replace(
/<link[^>]*rel=["']stylesheet["'][^>]*>/gi,
''
)
// Add our CSS link tag that Beasties can resolve
html = html.replace(
'</head>',
`<link rel="stylesheet" href="${cssInfo.href}">\n</head>`
)
// Process with Beasties
const processedHtml = await beasties.process(html)
// Extract the critical CSS that Beasties inlined
// Beasties adds a <style> tag with data-href attribute
let criticalCss = ''
const criticalStyleMatch = processedHtml.match(
/<style[^>]*data-href[^>]*>([\s\S]*?)<\/style>/i
)
if (criticalStyleMatch) {
criticalCss = criticalStyleMatch[1]
} else {
// Try to find any new style tags (not our placeholders)
const newStyleMatches = processedHtml.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)
for (const match of newStyleMatches) {
// Skip if this looks like a placeholder position
if (!match[0].includes('existing-style-placeholder')) {
criticalCss += match[1]
}
}
}
if (!criticalCss || criticalCss.trim().length === 0) {
console.warn(` Warning: No critical CSS extracted for ${page.template}`)
console.warn(` Beasties may not have found matching selectors in the HTML`)
results.push({ page, success: false, error: 'No critical CSS extracted' })
continue
}
// Clean up the CSS - remove any remaining placeholders or artifacts
criticalCss = criticalCss.trim()
// Ensure output subdirectory exists (for templates like _pages/_page)
await mkdir(dirname(outputPath), { recursive: true })
// Write the critical CSS file
await writeFile(outputPath, criticalCss, 'utf-8')
const sizeKb = (Buffer.byteLength(criticalCss, 'utf-8') / 1024).toFixed(2)
console.log(` Created: ${outputFilename} (${sizeKb} KB)`)
results.push({ page, success: true, size: sizeKb })
} catch (error) {
console.error(` Error processing ${page.template}: ${error.message}`)
results.push({ page, success: false, error: error.message })
}
}
console.log('\n--- Summary ---')
const successful = results.filter((r) => r.success)
const failed = results.filter((r) => !r.success)
console.log(`Generated: ${successful.length}/${results.length} critical CSS files`)
if (failed.length > 0) {
console.log('\nFailed:')
failed.forEach((r) => console.log(` - ${r.page.template}: ${r.error}`))
}
return failed.length === 0
}
// Main execution
const baseUrl = process.env.CRITICAL_URL
if (!baseUrl) {
console.error('Error: CRITICAL_URL environment variable is required.')
console.error('Example: CRITICAL_URL=https://supergeekery.ddev.site npm run generate-critical')
process.exit(1)
}
// Remove trailing slashes
const normalizedUrl = baseUrl.replace(/\/+$/, '')
generateCriticalCss(normalizedUrl)
.then((success) => {
process.exit(success ? 0 : 1)
})
.catch((error) => {
console.error('Fatal error:', error)
process.exit(1)
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment