Created
June 8, 2026 15:53
-
-
Save johnfmorton/7d886854f0a93fd4933b454f27851363 to your computer and use it in GitHub Desktop.
Script used to create Critical CSS with Vite and Craft CMS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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