Skip to content

Commit c5a146d

Browse files
authored
feat: add UnoCSS provider for OG image class resolution (#442)
1 parent b139c20 commit c5a146d

19 files changed

Lines changed: 1233 additions & 553 deletions

build.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export default defineBuildConfig({
3030
'fontless',
3131
'unifont',
3232
'tailwindcss',
33+
// unocss (optional - only used when @unocss/nuxt is present)
34+
'@unocss/core',
35+
'@unocss/config',
36+
'unconfig',
37+
'unconfig-core',
38+
'@quansync/fs',
39+
'quansync',
40+
'quansync/macro',
3341
// postcss packages (transitive deps of tailwindcss peer dep)
3442
'postcss-calc',
3543
'postcss-selector-parser',

src/build/css/css-classes.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import type { ElementNode } from '@vue/compiler-core'
2+
import type { ConsolaInstance } from 'consola'
3+
import type { OgImageComponent } from '../../runtime/types'
4+
import { existsSync, mkdirSync } from 'node:fs'
5+
import { readFile, writeFile } from 'node:fs/promises'
6+
import { join } from 'pathe'
7+
import { walkTemplateAst } from './css-utils'
8+
9+
// Lazy-loaded to reduce startup memory
10+
let parseSfc: typeof import('@vue/compiler-sfc').parse | undefined
11+
12+
async function loadParser() {
13+
if (!parseSfc)
14+
parseSfc = (await import('@vue/compiler-sfc')).parse
15+
return parseSfc
16+
}
17+
18+
interface CssClassCache {
19+
// keyed by component hash (already computed from path+mtime)
20+
files: Record<string, string[]>
21+
}
22+
23+
const ELEMENT_NODE = 1
24+
const ATTRIBUTE_NODE = 6
25+
const DIRECTIVE_NODE = 7
26+
27+
/**
28+
* Extract classes from a single Vue component using AST parsing.
29+
*/
30+
async function extractClassesFromVue(code: string): Promise<string[]> {
31+
const parse = await loadParser()
32+
const { descriptor } = parse(code)
33+
if (!descriptor.template?.ast)
34+
return []
35+
36+
const classes: string[] = []
37+
38+
walkTemplateAst(descriptor.template.ast.children, (node) => {
39+
if (node.type !== ELEMENT_NODE)
40+
return
41+
42+
const el = node as ElementNode
43+
44+
for (const prop of el.props) {
45+
// Static class="..."
46+
if (prop.type === ATTRIBUTE_NODE && prop.name === 'class' && prop.value) {
47+
for (const cls of prop.value.content.split(/\s+/)) {
48+
if (cls && !cls.includes('{') && !cls.includes('$'))
49+
classes.push(cls)
50+
}
51+
}
52+
53+
// Dynamic :class bindings
54+
if (prop.type === DIRECTIVE_NODE && prop.name === 'bind' && prop.arg?.type === 4 && (prop.arg as any).content === 'class') {
55+
const expr = prop.exp
56+
if (expr?.type === 4) {
57+
// Simple expression - extract string literals
58+
const content = (expr as any).content as string
59+
extractClassesFromExpression(content, classes)
60+
}
61+
}
62+
}
63+
})
64+
65+
return classes
66+
}
67+
68+
/**
69+
* Extract class names from a Vue expression string.
70+
* Handles: 'class', `class`, { 'class': cond }, [cond ? 'a' : 'b'], arrays, objects
71+
*/
72+
function extractClassesFromExpression(expr: string, classes: string[]): void {
73+
// Match all quoted strings (single, double, backtick)
74+
for (const match of expr.matchAll(/['"`]([\w:.\-/]+)['"`]/g)) {
75+
const cls = match[1]
76+
if (cls && !cls.includes('{') && !cls.includes('$') && !cls.includes('/'))
77+
classes.push(cls)
78+
}
79+
}
80+
81+
/**
82+
* Extract all static class names from OG image components.
83+
* Uses pre-computed component hashes for caching (no glob/stat needed).
84+
*/
85+
export async function scanComponentClasses(
86+
components: OgImageComponent[],
87+
logger?: ConsolaInstance,
88+
cacheDir?: string,
89+
): Promise<Set<string>> {
90+
const classes = new Set<string>()
91+
92+
// Load cache if available (keyed by component hash)
93+
const cacheFile = cacheDir ? join(cacheDir, 'cache', 'og-image', 'css-classes.json') : null
94+
let cache: CssClassCache = { files: {} }
95+
if (cacheFile && existsSync(cacheFile)) {
96+
cache = await readFile(cacheFile, 'utf-8')
97+
.then(c => JSON.parse(c) as CssClassCache)
98+
.catch(() => ({ files: {} }))
99+
}
100+
101+
const seenHashes = new Set<string>()
102+
let cacheHits = 0
103+
let cacheMisses = 0
104+
105+
// Process components sequentially - one file in memory at a time
106+
for (const component of components) {
107+
if (!component.path)
108+
continue
109+
110+
seenHashes.add(component.hash)
111+
112+
// Use cached classes if hash matches
113+
const cached = cache.files[component.hash]
114+
if (cached) {
115+
cacheHits++
116+
for (const cls of cached)
117+
classes.add(cls)
118+
continue
119+
}
120+
121+
// Cache miss - read and parse file
122+
cacheMisses++
123+
logger?.debug(`CSS: Scanning component ${component.path}`)
124+
const content = await readFile(component.path, 'utf-8').catch(() => null)
125+
if (!content)
126+
continue
127+
128+
const fileClasses = await extractClassesFromVue(content)
129+
cache.files[component.hash] = fileClasses
130+
131+
for (const cls of fileClasses)
132+
classes.add(cls)
133+
}
134+
135+
// Cleanup stale cache entries (components no longer present)
136+
for (const hash of Object.keys(cache.files)) {
137+
if (!seenHashes.has(hash))
138+
delete cache.files[hash]
139+
}
140+
141+
// Write cache
142+
if (cacheFile) {
143+
const cacheParent = join(cacheDir!, 'cache', 'og-image')
144+
mkdirSync(cacheParent, { recursive: true })
145+
await writeFile(cacheFile, JSON.stringify(cache))
146+
}
147+
148+
if (cacheHits > 0 || cacheMisses > 0)
149+
logger?.debug(`CSS: Class cache - ${cacheHits} hits, ${cacheMisses} misses`)
150+
151+
return classes
152+
}
153+
154+
/**
155+
* Filter classes to only those that need CSS processing.
156+
* Excludes responsive prefixes (handled at runtime) and unsupported classes.
157+
*/
158+
export function filterProcessableClasses(classes: Set<string>): string[] {
159+
const processable: string[] = []
160+
const responsivePrefixes = ['sm:', 'md:', 'lg:', 'xl:', '2xl:']
161+
162+
for (const cls of classes) {
163+
// Skip responsive variants - handled at runtime
164+
if (responsivePrefixes.some(p => cls.startsWith(p))) {
165+
const baseClass = cls.replace(/^(sm|md|lg|xl|2xl):/, '')
166+
if (baseClass)
167+
processable.push(baseClass)
168+
continue
169+
}
170+
171+
// Skip state variants that Satori can't handle
172+
if (cls.includes('hover:') || cls.includes('focus:') || cls.includes('active:'))
173+
continue
174+
175+
// Skip dark mode variants
176+
if (cls.startsWith('dark:'))
177+
continue
178+
179+
processable.push(cls)
180+
}
181+
182+
return [...new Set(processable)]
183+
}

src/build/css/css-provider.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Nuxt } from '@nuxt/schema'
2+
import { resolveModulePath } from 'exsolve'
3+
4+
export interface CssProvider {
5+
name: 'tailwind' | 'unocss'
6+
/**
7+
* Resolve utility classes to inline styles for Satori.
8+
* @param classes Array of class names to resolve
9+
* @returns Map of class name → CSS properties (kebab-case)
10+
*/
11+
resolveClassesToStyles: (classes: string[]) => Promise<Record<string, Record<string, string>>>
12+
/**
13+
* Extract theme metadata (fonts, breakpoints, colors).
14+
*/
15+
extractMetadata?: () => Promise<CssMetadata>
16+
/**
17+
* Clear cached compiler state (for HMR).
18+
*/
19+
clearCache?: () => void
20+
}
21+
22+
export interface CssMetadata {
23+
fontVars?: Record<string, string>
24+
breakpoints?: Record<string, number>
25+
colors?: Record<string, string | Record<string, string>>
26+
}
27+
28+
/**
29+
* Detect which CSS framework is being used.
30+
* Priority: @unocss/nuxt module → tailwindcss package
31+
*/
32+
export function detectCssProvider(nuxt: Nuxt): 'tailwind' | 'unocss' | null {
33+
const modules = nuxt.options.modules.flat()
34+
35+
// Check for @unocss/nuxt module
36+
if (modules.some(m => typeof m === 'string' && m.includes('@unocss/nuxt')))
37+
return 'unocss'
38+
39+
// Check for tailwindcss package
40+
if (resolveModulePath('tailwindcss', { from: nuxt.options.rootDir }))
41+
return 'tailwind'
42+
43+
return null
44+
}

0 commit comments

Comments
 (0)