Compare commits

...
Sign in to create a new pull request.

24 commits

Author SHA1 Message Date
Robin Malfait
a99bcfba60
use incremental builds in PostCSS 2024-03-08 18:36:42 +01:00
Robin Malfait
316190fe5a
start incremental rebuild work on PostCss 2024-03-08 18:36:42 +01:00
Robin Malfait
ac3f03a07e
rename tailwindcss-v4 to @tailwindcss/postcss 2024-03-08 18:36:42 +01:00
Robin Malfait
a45ae05fca
improve checking if new candidates were added or not 2024-03-08 18:36:42 +01:00
Robin Malfait
145df6ca53
encapsulate invalidRawCandidates 2024-03-08 18:36:42 +01:00
Robin Malfait
d7ec348412
rename parseAstNode to compileAstNodes 2024-03-08 18:36:42 +01:00
Robin Malfait
e05e5ece43
expose methods instead of raw DefaultMaps 2024-03-08 18:36:42 +01:00
Robin Malfait
ce38fcde3e
only optimize using Lightning CSS if the compiled css actually changed 2024-03-08 18:36:42 +01:00
Robin Malfait
a0f23d3306
do not clear the Rust cache
The candidates from the content files are still going to be correct as
long as the file didn't change.
2024-03-08 18:36:41 +01:00
Robin Malfait
115417fed4
move cached parsers to designSystem 2024-03-08 18:36:41 +01:00
Robin Malfait
d04a17c102
drop unnecessary comment 2024-03-08 18:36:41 +01:00
Robin Malfait
a19bc73e70
build allValidCandidates from all rawCandidates that are not invalid 2024-03-08 18:36:41 +01:00
Robin Malfait
50da0a1be2
allow any iterable string as input
This way we can pass it an array or a set without first converting it.
2024-03-08 18:36:41 +01:00
Robin Malfait
02b1794684
drop todo
This only takes ~0.4ms in the tailwindcss.com repo
2024-03-08 18:36:41 +01:00
Robin Malfait
48b56017cd
hoist invalidRawCandidates
This way the cache can start filling up before a rebuild happens
2024-03-08 18:36:41 +01:00
Robin Malfait
c65598e12c
cache compiledCss 2024-03-08 18:36:41 +01:00
Robin Malfait
62ee42c30c
ensure input file is an import dependency 2024-03-08 18:36:41 +01:00
Robin Malfait
27e06c76d2
cache all the things 2024-03-08 18:36:41 +01:00
Robin Malfait
6282e2b22e
ensure we don't crash on deleted files 2024-03-08 18:36:41 +01:00
Robin Malfait
1b3ad0db65
WIP 2024-03-08 18:36:40 +01:00
Robin Malfait
b0353871f7
update tests to reflect order change 2024-03-08 18:36:40 +01:00
Robin Malfait
43eecb981a
remove localeCompare calls 2024-03-08 18:36:40 +01:00
Robin Malfait
1e0b35d836
move sorting logic to the end 2024-03-08 18:36:40 +01:00
Robin Malfait
d6e5858e56
return rebuild function from compile 2024-03-08 18:36:40 +01:00
17 changed files with 435 additions and 269 deletions

View file

@ -433,9 +433,9 @@ fn read_all_files_sync(changed_content: Vec<ChangedContent>) -> Vec<Vec<u8>> {
changed_content changed_content
.into_iter() .into_iter()
.map(|c| match (c.file, c.content) { .filter_map(|c| match (c.file, c.content) {
(Some(file), None) => std::fs::read(file).unwrap(), (Some(file), None) => std::fs::read(file).ok(),
(None, Some(content)) => content.into_bytes(), (None, Some(content)) => Some(content.into_bytes()),
_ => Default::default(), _ => Default::default(),
}) })
.collect() .collect()

View file

@ -1,12 +1,5 @@
import watcher from '@parcel/watcher' import watcher from '@parcel/watcher'
import { import { IO, Parsing, scanDir, scanFiles, type ChangedContent } from '@tailwindcss/oxide'
IO,
Parsing,
clearCache,
scanDir,
scanFiles,
type ChangedContent,
} from '@tailwindcss/oxide'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
@ -99,23 +92,41 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
args['--input'] ?? base, args['--input'] ?? base,
) )
let previous = {
css: '',
optimizedCss: '',
}
async function write(css: string, args: Result<ReturnType<typeof options>>) {
let output = css
// Optimize the output
if (args['--minify'] || args['--optimize']) {
if (css !== previous.css) {
let optimizedCss = optimizeCss(css, {
file: args['--input'] ?? 'input.css',
minify: args['--minify'] ?? false,
})
previous.css = css
previous.optimizedCss = optimizedCss
output = optimizedCss
} else {
output = previous.optimizedCss
}
}
// Write the output
if (args['--output']) {
await outputFile(args['--output'], output)
} else {
println(output)
}
}
// Compile the input // Compile the input
let result = compile(input, candidates) let result = compile(input, candidates)
// Optimize the output await write(result.css, args)
if (args['--minify'] || args['--optimize']) {
result = optimizeCss(result, {
file: args['--input'] ?? 'input.css',
minify: args['--minify'] ?? false,
})
}
// Write the output
if (args['--output']) {
await outputFile(args['--output'], result)
} else {
println(result)
}
let end = process.hrtime.bigint() let end = process.hrtime.bigint()
eprintln(header()) eprintln(header())
@ -161,26 +172,14 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Re-compile the input // Re-compile the input
let start = process.hrtime.bigint() let start = process.hrtime.bigint()
// Track the compiled CSS
let compiledCss = ''
// Scan the entire `base` directory for full rebuilds. // Scan the entire `base` directory for full rebuilds.
if (rebuildStrategy === 'full') { if (rebuildStrategy === 'full') {
// Clear the cache because we need to re-scan the entire directory.
clearCache()
// Re-scan the directory to get the new `candidates`. // Re-scan the directory to get the new `candidates`.
candidates = scanDir({ base }).candidates candidates = scanDir({ base }).candidates
}
// Scan changed files only for incremental rebuilds.
else if (rebuildStrategy === 'incremental') {
let uniqueCandidates = new Set(candidates)
for (let candidate of scanFiles(changedFiles, IO.Sequential | Parsing.Sequential)) {
uniqueCandidates.add(candidate)
}
candidates = Array.from(uniqueCandidates)
}
// Resolve the input
if (rebuildStrategy === 'full') {
// Collect the new `input` and `cssImportPaths`. // Collect the new `input` and `cssImportPaths`.
;[input, cssImportPaths] = await handleImports( ;[input, cssImportPaths] = await handleImports(
args['--input'] args['--input']
@ -190,25 +189,19 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
`, `,
args['--input'] ?? base, args['--input'] ?? base,
) )
result = compile(input, candidates)
compiledCss = result.css
} }
// Compile the input // Scan changed files only for incremental rebuilds.
let result = compile(input, candidates) else if (rebuildStrategy === 'incremental') {
let newCandidates = scanFiles(changedFiles, IO.Sequential | Parsing.Sequential)
// Optimize the output compiledCss = result.rebuild(newCandidates)
if (args['--minify'] || args['--optimize']) {
result = optimizeCss(result, {
file: args['--input'] ?? 'input.css',
minify: args['--minify'] ?? false,
})
} }
// Write the output await write(compiledCss, args)
if (args['--output']) {
await outputFile(args['--output'], result)
} else {
println(result)
}
let end = process.hrtime.bigint() let end = process.hrtime.bigint()
eprintln(`Done in ${formatDuration(end - start)}`) eprintln(`Done in ${formatDuration(end - start)}`)
@ -243,7 +236,9 @@ function handleImports(
// Relevant specification: // Relevant specification:
// - CSS Import Resolve: https://csstools.github.io/css-import-resolve/ // - CSS Import Resolve: https://csstools.github.io/css-import-resolve/
if (!input.includes('@import')) return [input, []] if (!input.includes('@import')) {
return [input, [file]]
}
return postcss() return postcss()
.use(atImport()) .use(atImport())
@ -253,6 +248,8 @@ function handleImports(
// Use `result.messages` to get the imported files. This also includes the // Use `result.messages` to get the imported files. This also includes the
// current file itself. // current file itself.
result.messages.filter((msg) => msg.type === 'postcss-import').map((msg) => msg.file), [file].concat(
result.messages.filter((msg) => msg.type === 'dependency').map((msg) => msg.file),
),
]) ])
} }

View file

@ -1,8 +1,30 @@
import { scanDir } from '@tailwindcss/oxide' import { scanDir } from '@tailwindcss/oxide'
import fs from 'fs'
import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss' import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss'
import postcssImport from 'postcss-import' import postcssImport from 'postcss-import'
import { compile, optimizeCss } from 'tailwindcss' import { compile, optimizeCss } from 'tailwindcss'
/**
* A Map that can generate default values for keys that don't exist.
* Generated default values are added to the map to avoid recomputation.
*/
class DefaultMap<T = string, V = any> extends Map<T, V> {
constructor(private factory: (key: T, self: DefaultMap<T, V>) => V) {
super()
}
get(key: T): V {
let value = super.get(key)
if (value === undefined) {
value = this.factory(key, this)
this.set(key, value)
}
return value
}
}
type PluginOptions = { type PluginOptions = {
// The base directory to scan for class candidates. // The base directory to scan for class candidates.
base?: string base?: string
@ -15,13 +37,54 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
let base = opts.base ?? process.cwd() let base = opts.base ?? process.cwd()
let optimize = opts.optimize ?? process.env.NODE_ENV === 'production' let optimize = opts.optimize ?? process.env.NODE_ENV === 'production'
let cache = new DefaultMap(() => {
return {
mtimes: new Map<string, number>(),
rebuild: null as null | ReturnType<typeof compile>['rebuild'],
previousCss: '',
previousOptimizedCss: '',
}
})
return { return {
postcssPlugin: 'tailwindcss-v4', postcssPlugin: '@tailwindcss/postcss',
plugins: [ plugins: [
// We need to run `postcss-import` first to handle `@import` rules. // We need to run `postcss-import` first to handle `@import` rules.
postcssImport(), postcssImport(),
(root, result) => { (root, result) => {
let from = result.opts.from ?? ''
let context = cache.get(from)
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
// Bookkeeping — track file modification times to CSS files
{
let changedTime = fs.statSync(from, { throwIfNoEntry: false })?.mtimeMs ?? null
if (changedTime !== null) {
let prevTime = context.mtimes.get(from)
if (prevTime !== changedTime) {
rebuildStrategy = 'full'
context.mtimes.set(from, changedTime)
}
} else {
rebuildStrategy = 'full'
}
for (let message of result.messages) {
if (message.type === 'dependency') {
let file = message.file as string
let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
if (changedTime !== null) {
let prevTime = context.mtimes.get(file)
if (prevTime !== changedTime) {
rebuildStrategy = 'full'
context.mtimes.set(file, changedTime)
}
}
}
}
}
let hasApply = false let hasApply = false
let hasTailwind = false let hasTailwind = false
@ -40,22 +103,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
// Do nothing if neither `@tailwind` nor `@apply` is used // Do nothing if neither `@tailwind` nor `@apply` is used
if (!hasTailwind && !hasApply) return if (!hasTailwind && !hasApply) return
function replaceCss(css: string) { let css = ''
root.removeAll()
let output = css
if (optimize) {
output = optimizeCss(output, {
minify: typeof optimize === 'object' ? optimize.minify : false,
})
}
root.append(postcss.parse(output, result.opts))
}
// No `@tailwind` means we don't have to look for candidates
if (!hasTailwind) {
replaceCss(compile(root.toString(), []))
return
}
// Look for candidates used to generate the CSS // Look for candidates used to generate the CSS
let { candidates, files, globs } = scanDir({ base, globs: true }) let { candidates, files, globs } = scanDir({ base, globs: true })
@ -64,7 +112,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
for (let file of files) { for (let file of files) {
result.messages.push({ result.messages.push({
type: 'dependency', type: 'dependency',
plugin: 'tailwindcss-v4', plugin: '@tailwindcss/postcss',
file, file,
parent: result.opts.from, parent: result.opts.from,
}) })
@ -76,14 +124,49 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
for (let { base, glob } of globs) { for (let { base, glob } of globs) {
result.messages.push({ result.messages.push({
type: 'dir-dependency', type: 'dir-dependency',
plugin: 'tailwindcss-v4', plugin: '@tailwindcss/postcss',
dir: base, dir: base,
glob, glob,
parent: result.opts.from, parent: result.opts.from,
}) })
} }
replaceCss(compile(root.toString(), candidates)) if (rebuildStrategy === 'full') {
if (hasTailwind) {
let compileResult = compile(root.toString(), candidates)
css = compileResult.css
context.rebuild = compileResult.rebuild.bind(compileResult)
} else {
css = compile(root.toString(), []).css
}
} else if (rebuildStrategy === 'incremental') {
css = context.rebuild!(candidates)
}
function replaceCss(css: string) {
root.removeAll()
root.append(postcss.parse(css, result.opts))
}
// Replace CSS
if (css === context.previousCss) {
if (optimize) {
replaceCss(context.previousOptimizedCss)
} else {
replaceCss(css)
}
} else {
if (optimize) {
let optimizedCss = optimizeCss(css, {
minify: typeof optimize === 'object' ? optimize.minify : false,
})
replaceCss(optimizedCss)
context.previousOptimizedCss = optimizedCss
} else {
replaceCss(css)
}
context.previousCss = css
}
}, },
], ],
} }

View file

@ -61,7 +61,7 @@ export default function tailwindcss(): Plugin[] {
} }
function generateCss(css: string) { function generateCss(css: string) {
return compile(css, Array.from(candidates)) return compile(css, Array.from(candidates)).css
} }
function generateOptimizedCss(css: string) { function generateOptimizedCss(css: string) {

View file

@ -105,6 +105,13 @@ export function toCss(ast: AstNode[]) {
return css return css
} }
if (node.selector === '@tailwind utilities') {
for (let child of node.nodes) {
css += stringify(child, depth)
}
return css
}
// Print at-rules without nodes with a `;` instead of an empty block. // Print at-rules without nodes with a `;` instead of an empty block.
// //
// E.g.: // E.g.:

View file

@ -1,9 +1,8 @@
import { scanDir } from '@tailwindcss/oxide' import { scanDir } from '@tailwindcss/oxide'
import { bench } from 'vitest' import { bench } from 'vitest'
import { parseCandidate, parseVariant } from './candidate' import { parseCandidate } from './candidate'
import { buildDesignSystem } from './design-system' import { buildDesignSystem } from './design-system'
import { Theme } from './theme' import { Theme } from './theme'
import { DefaultMap } from './utils/default-map'
// FOLDER=path/to/folder vitest bench // FOLDER=path/to/folder vitest bench
const root = process.env.FOLDER || process.cwd() const root = process.env.FOLDER || process.cwd()
@ -15,10 +14,6 @@ const designSystem = buildDesignSystem(new Theme())
bench('parseCandidate', () => { bench('parseCandidate', () => {
for (let candidate of result.candidates) { for (let candidate of result.candidates) {
parseCandidate( parseCandidate(candidate, designSystem)
candidate,
designSystem.utilities,
new DefaultMap((variant, map) => parseVariant(variant, designSystem.variants, map)),
)
} }
}) })

View file

@ -1,7 +1,7 @@
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { parseCandidate, parseVariant } from './candidate' import { buildDesignSystem } from './design-system'
import { Theme } from './theme'
import { Utilities } from './utilities' import { Utilities } from './utilities'
import { DefaultMap } from './utils/default-map'
import { Variants } from './variants' import { Variants } from './variants'
function run( function run(
@ -11,11 +11,12 @@ function run(
utilities ??= new Utilities() utilities ??= new Utilities()
variants ??= new Variants() variants ??= new Variants()
let parsedVariants = new DefaultMap((variant, map) => { let designSystem = buildDesignSystem(new Theme())
return parseVariant(variant, variants!, map)
})
return parseCandidate(candidate, utilities, parsedVariants) designSystem.utilities = utilities
designSystem.variants = variants
return designSystem.parseCandidate(candidate)
} }
it('should skip unknown utilities', () => { it('should skip unknown utilities', () => {

View file

@ -1,3 +1,4 @@
import type { DesignSystem } from './design-system'
import { decodeArbitraryValue } from './utils/decode-arbitrary-value' import { decodeArbitraryValue } from './utils/decode-arbitrary-value'
import { segment } from './utils/segment' import { segment } from './utils/segment'
@ -206,14 +207,7 @@ export type Candidate =
important: boolean important: boolean
} }
export function parseCandidate( export function parseCandidate(input: string, designSystem: DesignSystem): Candidate | null {
input: string,
utilities: {
has: (value: string) => boolean
kind: (root: string) => Omit<Candidate['kind'], 'arbitrary'>
},
parsedVariants: { get: (value: string) => Variant | null },
): Candidate | null {
// hover:focus:underline // hover:focus:underline
// ^^^^^ ^^^^^^ -> Variants // ^^^^^ ^^^^^^ -> Variants
// ^^^^^^^^^ -> Base // ^^^^^^^^^ -> Base
@ -228,7 +222,7 @@ export function parseCandidate(
let parsedCandidateVariants: Variant[] = [] let parsedCandidateVariants: Variant[] = []
for (let variant of rawVariants) { for (let variant of rawVariants) {
let parsedVariant = parsedVariants.get(variant) let parsedVariant = designSystem.parseVariant(variant)
if (parsedVariant === null) return null if (parsedVariant === null) return null
// Variants are applied left-to-right meaning that any representing pseudo- // Variants are applied left-to-right meaning that any representing pseudo-
@ -320,7 +314,7 @@ export function parseCandidate(
base = base.slice(1) base = base.slice(1)
} }
let [root, value] = findRoot(base, utilities) let [root, value] = findRoot(base, designSystem.utilities)
let modifierSegment: string | null = null let modifierSegment: string | null = null
@ -335,13 +329,13 @@ export function parseCandidate(
modifierSegment = rootModifierSegment modifierSegment = rootModifierSegment
// Try to find the root and value, without the modifier present // Try to find the root and value, without the modifier present
;[root, value] = findRoot(rootWithoutModifier, utilities) ;[root, value] = findRoot(rootWithoutModifier, designSystem.utilities)
} }
// If there's no root, the candidate isn't a valid class and can be discarded. // If there's no root, the candidate isn't a valid class and can be discarded.
if (root === null) return null if (root === null) return null
let kind = utilities.kind(root) let kind = designSystem.utilities.kind(root)
if (kind === 'static') { if (kind === 'static') {
if (value !== null) return null if (value !== null) return null
@ -475,15 +469,7 @@ function parseModifier(modifier: string): CandidateModifier {
} }
} }
export function parseVariant( export function parseVariant(variant: string, designSystem: DesignSystem): Variant | null {
variant: string,
variants: {
has: (value: string) => boolean
kind: (root: string) => Omit<Variant['kind'], 'arbitrary'>
compounds: (root: string) => boolean
},
parsedVariants: { get: (value: string) => Variant | null },
): Variant | null {
// Arbitrary variants // Arbitrary variants
if (variant[0] === '[' && variant[variant.length - 1] === ']') { if (variant[0] === '[' && variant[variant.length - 1] === ']') {
/** /**
@ -535,20 +521,20 @@ export function parseVariant(
// - `group-hover/foo/bar` // - `group-hover/foo/bar`
if (additionalModifier) return null if (additionalModifier) return null
let [root, value] = findRoot(variantWithoutModifier, variants) let [root, value] = findRoot(variantWithoutModifier, designSystem.variants)
// Variant is invalid, therefore the candidate is invalid and we can skip // Variant is invalid, therefore the candidate is invalid and we can skip
// continue parsing it. // continue parsing it.
if (root === null) return null if (root === null) return null
switch (variants.kind(root)) { switch (designSystem.variants.kind(root)) {
case 'static': { case 'static': {
if (value !== null) return null if (value !== null) return null
return { return {
kind: 'static', kind: 'static',
root, root,
compounds: variants.compounds(root), compounds: designSystem.variants.compounds(root),
} }
} }
@ -564,7 +550,7 @@ export function parseVariant(
kind: 'arbitrary', kind: 'arbitrary',
value: decodeArbitraryValue(value.slice(1, -1)), value: decodeArbitraryValue(value.slice(1, -1)),
}, },
compounds: variants.compounds(root), compounds: designSystem.variants.compounds(root),
} }
} }
@ -573,14 +559,14 @@ export function parseVariant(
root, root,
modifier: modifier === null ? null : parseModifier(modifier), modifier: modifier === null ? null : parseModifier(modifier),
value: { kind: 'named', value }, value: { kind: 'named', value },
compounds: variants.compounds(root), compounds: designSystem.variants.compounds(root),
} }
} }
case 'compound': { case 'compound': {
if (value === null) return null if (value === null) return null
let subVariant = parsedVariants.get(value) let subVariant = designSystem.parseVariant(value)
if (subVariant === null) return null if (subVariant === null) return null
if (subVariant.compounds === false) return null if (subVariant.compounds === false) return null
@ -589,7 +575,7 @@ export function parseVariant(
root, root,
modifier: modifier === null ? null : { kind: 'named', value: modifier }, modifier: modifier === null ? null : { kind: 'named', value: modifier },
variant: subVariant, variant: subVariant,
compounds: variants.compounds(root), compounds: designSystem.variants.compounds(root),
} }
} }
} }

View file

@ -1,120 +1,58 @@
import { rule, type AstNode, type Rule } from './ast' import { rule, type AstNode, type Rule } from './ast'
import { parseCandidate, parseVariant, type Candidate, type Variant } from './candidate' import { type Candidate, type Variant } from './candidate'
import { type DesignSystem } from './design-system' import { type DesignSystem } from './design-system'
import GLOBAL_PROPERTY_ORDER from './property-order' import GLOBAL_PROPERTY_ORDER from './property-order'
import { DefaultMap } from './utils/default-map'
import { escape } from './utils/escape' import { escape } from './utils/escape'
import type { Variants } from './variants' import type { Variants } from './variants'
export function compileCandidates( export function compileCandidates(
rawCandidates: string[], rawCandidates: Iterable<string>,
designSystem: DesignSystem, designSystem: DesignSystem,
{ throwOnInvalidCandidate = false } = {}, { throwOnInvalidCandidate = false } = {},
) { ) {
// Ensure the candidates are sorted alphabetically
rawCandidates.sort()
let nodeSorting = new Map< let nodeSorting = new Map<
AstNode, AstNode,
{ properties: number[]; variants: bigint; candidate: string } { properties: number[]; variants: bigint; candidate: string }
>() >()
let astNodes: AstNode[] = [] let astNodes: AstNode[] = []
// A lazy map implementation that will return the variant if it exists. If it
// doesn't exist yet, the raw string variant will be parsed and added to the
// map.
let parsedVariants: DefaultMap<string, Variant | null> = new DefaultMap((variant, map) => {
return parseVariant(variant, designSystem.variants, map)
})
let candidates = new Map<Candidate, string>() let candidates = new Map<Candidate, string>()
// Parse candidates and variants // Parse candidates and variants
for (let rawCandidate of rawCandidates) { for (let rawCandidate of rawCandidates) {
let candidate = parseCandidate(rawCandidate, designSystem.utilities, parsedVariants) let candidate = designSystem.parseCandidate(rawCandidate)
if (candidate === null) { if (candidate === null) {
if (throwOnInvalidCandidate) { if (throwOnInvalidCandidate) {
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`) throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
} }
designSystem.markInvalidCandidate(rawCandidate)
continue // Bail, invalid candidate continue // Bail, invalid candidate
} }
candidates.set(candidate, rawCandidate) candidates.set(candidate, rawCandidate)
} }
// Sort the variants // Sort the variants
let variants = Array.from(parsedVariants.values()).sort((a, z) => { let variants = designSystem.getUsedVariants().sort((a, z) => {
return designSystem.variants.compare(a, z) return designSystem.variants.compare(a, z)
}) })
// Create the AST // Create the AST
next: for (let [candidate, rawCandidate] of candidates) { next: for (let [candidate, rawCandidate] of candidates) {
let nodes: AstNode[] = [] let astNode = designSystem.compileAstNodes(rawCandidate)
if (astNode === null) {
// Handle arbitrary properties if (throwOnInvalidCandidate) {
if (candidate.kind === 'arbitrary') { throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
let compileFn = designSystem.utilities.getArbitrary()
// Build the node
let compiledNodes = compileFn(candidate)
if (compiledNodes === undefined) {
if (throwOnInvalidCandidate) {
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
}
continue next
} }
designSystem.markInvalidCandidate(rawCandidate)
nodes = compiledNodes continue next
} }
// Handle named utilities let { node, propertySort } = astNode
else if (candidate.kind === 'static' || candidate.kind === 'functional') {
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because if the `candidate.root` didn't exist, `parseCandidate`
// would have returned `null` and we would have returned early resulting
// in not hitting this code path.
let { compileFn } = designSystem.utilities.get(candidate.root)!
// Build the node
let compiledNodes = compileFn(candidate)
if (compiledNodes === undefined) {
if (throwOnInvalidCandidate) {
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
}
continue next
}
nodes = compiledNodes
}
let propertySort = getPropertySort(nodes)
if (candidate.important) {
applyImportant(nodes)
}
let node: Rule = {
kind: 'rule',
selector: `.${escape(rawCandidate)}`,
nodes,
}
// Track the variant order which is a number with each bit representing a
// variant. This allows us to sort the rules based on the order of
// variants used.
let variantOrder = 0n let variantOrder = 0n
for (let variant of candidate.variants) { for (let variant of candidate.variants) {
let result = applyVariant(node, variant, designSystem.variants)
// When the variant results in `null`, it means that the variant cannot be
// applied to the rule. Discard the candidate and continue to the next
// one.
if (result === null) {
if (throwOnInvalidCandidate) {
throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`)
}
continue next
}
// Track the variant order which is a number with each bit representing a
// variant. This allows us to sort the rules based on the order of
// variants used.
variantOrder |= 1n << BigInt(variants.indexOf(variant)) variantOrder |= 1n << BigInt(variants.indexOf(variant))
} }
@ -153,7 +91,9 @@ export function compileCandidates(
// Sort by lowest property index first // Sort by lowest property index first
(aSorting.properties[offset] ?? Infinity) - (zSorting.properties[offset] ?? Infinity) || (aSorting.properties[offset] ?? Infinity) - (zSorting.properties[offset] ?? Infinity) ||
// Sort by most properties first, then by least properties // Sort by most properties first, then by least properties
zSorting.properties.length - aSorting.properties.length zSorting.properties.length - aSorting.properties.length ||
// Sort alphabetically
(aSorting.candidate < zSorting.candidate ? -1 : 1)
) )
}) })
@ -163,6 +103,65 @@ export function compileCandidates(
} }
} }
export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem) {
let candidate = designSystem.parseCandidate(rawCandidate)
if (candidate === null) return null
let nodes: AstNode[] = []
// Handle arbitrary properties
if (candidate.kind === 'arbitrary') {
let compileFn = designSystem.utilities.getArbitrary()
// Build the node
let compiledNodes = compileFn(candidate)
if (compiledNodes === undefined) return null
nodes = compiledNodes
}
// Handle named utilities
else if (candidate.kind === 'static' || candidate.kind === 'functional') {
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because if the `candidate.root` didn't exist, `parseCandidate`
// would have returned `null` and we would have returned early resulting
// in not hitting this code path.
let { compileFn } = designSystem.utilities.get(candidate.root)!
// Build the node
let compiledNodes = compileFn(candidate)
if (compiledNodes === undefined) return null
nodes = compiledNodes
}
let propertySort = getPropertySort(nodes)
if (candidate.important) {
applyImportant(nodes)
}
let node: Rule = {
kind: 'rule',
selector: `.${escape(rawCandidate)}`,
nodes,
}
for (let variant of candidate.variants) {
let result = applyVariant(node, variant, designSystem.variants)
// When the variant results in `null`, it means that the variant cannot be
// applied to the rule. Discard the candidate and continue to the next
// one.
if (result === null) return null
}
return {
node,
propertySort,
}
}
export function applyVariant(node: Rule, variant: Variant, variants: Variants): null | void { export function applyVariant(node: Rule, variant: Variant, variants: Variants): null | void {
if (variant.kind === 'arbitrary') { if (variant.kind === 'arbitrary') {
node.nodes = [rule(variant.selector, node.nodes)] node.nodes = [rule(variant.selector, node.nodes)]

View file

@ -1,9 +1,11 @@
import { toCss } from './ast' import { toCss } from './ast'
import { compileCandidates } from './compile' import { parseCandidate, parseVariant } from './candidate'
import { compileAstNodes, compileCandidates } from './compile'
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
import { getClassOrder } from './sort' import { getClassOrder } from './sort'
import type { Theme } from './theme' import type { Theme } from './theme'
import { Utilities, createUtilities } from './utilities' import { Utilities, createUtilities } from './utilities'
import { DefaultMap } from './utils/default-map'
import { Variants, createVariants } from './variants' import { Variants, createVariants } from './variants'
export type DesignSystem = { export type DesignSystem = {
@ -15,13 +17,31 @@ export type DesignSystem = {
getClassOrder(classes: string[]): [string, bigint | null][] getClassOrder(classes: string[]): [string, bigint | null][]
getClassList(): ClassEntry[] getClassList(): ClassEntry[]
getVariants(): VariantEntry[] getVariants(): VariantEntry[]
parseCandidate(candidate: string): ReturnType<typeof parseCandidate>
parseVariant(variant: string): ReturnType<typeof parseVariant>
compileAstNodes(candidate: string): ReturnType<typeof compileAstNodes>
getUsedVariants(): ReturnType<typeof parseVariant>[]
getAstNodeSize(): number
markInvalidCandidate(candidate: string): void
isInvalidCandidate(candidate: string): boolean
} }
export function buildDesignSystem(theme: Theme): DesignSystem { export function buildDesignSystem(theme: Theme): DesignSystem {
return { let utilities = createUtilities(theme)
let variants = createVariants(theme)
let invalidRawCandidates = new Set<string>()
let parsedVariants = new DefaultMap((variant) => parseVariant(variant, designSystem))
let parsedCandidates = new DefaultMap((candidate) => parseCandidate(candidate, designSystem))
let compiledAstNodes = new DefaultMap((candidate) => compileAstNodes(candidate, designSystem))
let designSystem: DesignSystem = {
theme, theme,
utilities: createUtilities(theme), utilities,
variants: createVariants(theme), variants,
candidatesToCss(classes: string[]) { candidatesToCss(classes: string[]) {
let result: (string | null)[] = [] let result: (string | null)[] = []
@ -47,5 +67,30 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
getVariants() { getVariants() {
return getVariants(this) return getVariants(this)
}, },
parseCandidate(candidate: string) {
return parsedCandidates.get(candidate)
},
parseVariant(variant: string) {
return parsedVariants.get(variant)
},
compileAstNodes(candidate: string) {
return compiledAstNodes.get(candidate)
},
getUsedVariants() {
return Array.from(parsedVariants.values())
},
getAstNodeSize() {
return compiledAstNodes.size
},
markInvalidCandidate(candidate: string) {
invalidRawCandidates.add(candidate)
},
isInvalidCandidate(candidate: string) {
return invalidRawCandidates.has(candidate)
},
} }
return designSystem
} }

View file

@ -93,9 +93,7 @@ describe('compiling CSS', () => {
.grid { .grid {
display: grid; display: grid;
} }"
@tailwind utilities;"
`) `)
}) })

View file

@ -6,7 +6,13 @@ import * as CSS from './css-parser'
import { buildDesignSystem } from './design-system' import { buildDesignSystem } from './design-system'
import { Theme } from './theme' import { Theme } from './theme'
export function compile(css: string, rawCandidates: string[]) { export function compile(
css: string,
rawCandidates: string[],
): {
rebuild(candidates: string[]): string
css: string
} {
let ast = CSS.parse(css) let ast = CSS.parse(css)
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
@ -97,11 +103,17 @@ export function compile(css: string, rawCandidates: string[]) {
let designSystem = buildDesignSystem(theme) let designSystem = buildDesignSystem(theme)
let tailwindUtilitiesNode: Rule | null = null
// Find `@tailwind utilities` and replace it with the actual generated utility // Find `@tailwind utilities` and replace it with the actual generated utility
// class CSS. // class CSS.
walk(ast, (node, { replaceWith }) => { walk(ast, (node) => {
if (node.kind === 'rule' && node.selector === '@tailwind utilities') { if (node.kind === 'rule' && node.selector === '@tailwind utilities') {
replaceWith(compileCandidates(rawCandidates, designSystem).astNodes) tailwindUtilitiesNode = node
// Set the `@tailwind utilities` nodes, to the actual generated CSS
node.nodes = compileCandidates(rawCandidates, designSystem).astNodes
// Stop walking after finding `@tailwind utilities` to avoid walking all // Stop walking after finding `@tailwind utilities` to avoid walking all
// of the generated CSS. This means `@tailwind utilities` can only appear // of the generated CSS. This means `@tailwind utilities` can only appear
// once per file but that's the intended usage at this point in time. // once per file but that's the intended usage at this point in time.
@ -162,7 +174,55 @@ export function compile(css: string, rawCandidates: string[]) {
}) })
} }
return toCss(ast) // Track all valid candidates, these are the incoming `rawCandidate` that
// resulted in a generated AST Node. All the other `rawCandidates` are invalid
// and should be ignored.
let allValidCandidates = new Set<string>()
for (let rawCandidate of rawCandidates) {
if (!designSystem.isInvalidCandidate(rawCandidate)) {
allValidCandidates.add(rawCandidate)
}
}
let compiledCss = toCss(ast)
return {
rebuild(newRawCandidates: string[]) {
let didChange = false
// Add all new candidates unless we know that they are invalid.
let prevSize = allValidCandidates.size
for (let candidate of newRawCandidates) {
if (!designSystem.isInvalidCandidate(candidate)) {
allValidCandidates.add(candidate)
didChange ||= allValidCandidates.size !== prevSize
}
}
// If no new candidates were added, we can return the original CSS. This
// currently assumes that we only add new candidates and never remove any.
if (!didChange) {
return compiledCss
}
if (tailwindUtilitiesNode) {
let previousAstNodeCount = designSystem.getAstNodeSize()
let newNodes = compileCandidates(allValidCandidates, designSystem).astNodes
// If no new ast nodes were generated, then we can return the original
// CSS. This currently assumes that we only add new ast nodes and never
// remove any.
if (previousAstNodeCount === designSystem.getAstNodeSize()) {
return compiledCss
}
tailwindUtilitiesNode.nodes = newNodes
compiledCss = toCss(ast)
}
return compiledCss
},
css: compiledCss,
}
} }
export function optimizeCss( export function optimizeCss(

View file

@ -1,8 +1,6 @@
import { decl, rule } from './ast' import { decl, rule } from './ast'
import { parseVariant, type Variant } from './candidate'
import { applyVariant } from './compile' import { applyVariant } from './compile'
import type { DesignSystem } from './design-system' import type { DesignSystem } from './design-system'
import { DefaultMap } from './utils/default-map'
interface ClassMetadata { interface ClassMetadata {
modifiers: string[] modifiers: string[]
@ -40,7 +38,7 @@ export function getClassList(design: DesignSystem): ClassEntry[] {
} }
} }
list.sort((a, b) => a[0].localeCompare(b[0])) list.sort((a, b) => (a[0] === b[0] ? 0 : a[0] < b[0] ? -1 : 1))
return list return list
} }
@ -60,9 +58,6 @@ export interface VariantEntry {
export function getVariants(design: DesignSystem) { export function getVariants(design: DesignSystem) {
let list: VariantEntry[] = [] let list: VariantEntry[] = []
let parsedVariants = new DefaultMap<string, Variant | null>((variant, map) =>
parseVariant(variant, design.variants, map),
)
for (let [root, variant] of design.variants.entries()) { for (let [root, variant] of design.variants.entries()) {
if (variant.kind === 'arbitrary') continue if (variant.kind === 'arbitrary') continue
@ -74,7 +69,7 @@ export function getVariants(design: DesignSystem) {
if (value) name += `-${value}` if (value) name += `-${value}`
if (modifier) name += `/${modifier}` if (modifier) name += `/${modifier}`
let variant = parsedVariants.get(name) let variant = design.parseVariant(name)
if (!variant) return [] if (!variant) return []

View file

@ -1,9 +1,9 @@
import { compile, optimizeCss } from '..' import { compile, optimizeCss } from '..'
export function compileCss(css: string, candidates: string[] = []) { export function compileCss(css: string, candidates: string[] = []) {
return optimizeCss(compile(css, candidates)).trim() return optimizeCss(compile(css, candidates).css).trim()
} }
export function run(candidates: string[]) { export function run(candidates: string[]) {
return optimizeCss(compile('@tailwind utilities;', candidates)).trim() return optimizeCss(compile('@tailwind utilities;', candidates).css).trim()
} }

View file

@ -1280,7 +1280,13 @@ test('supports', () => {
expect( expect(
run(['supports-gap:grid', 'supports-[display:grid]:flex', 'supports-[selector(A_>_B)]:flex']), run(['supports-gap:grid', 'supports-[display:grid]:flex', 'supports-[selector(A_>_B)]:flex']),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
"@supports (display: grid) { "@supports (gap: var(--tw)) {
.supports-gap\\:grid {
display: grid;
}
}
@supports (display: grid) {
.supports-\\[display\\:grid\\]\\:flex { .supports-\\[display\\:grid\\]\\:flex {
display: flex; display: flex;
} }
@ -1290,12 +1296,6 @@ test('supports', () => {
.supports-\\[selector\\(A_\\>_B\\)\\]\\:flex { .supports-\\[selector\\(A_\\>_B\\)\\]\\:flex {
display: flex; display: flex;
} }
}
@supports (gap: var(--tw)) {
.supports-gap\\:grid {
display: grid;
}
}" }"
`) `)
}) })
@ -1322,11 +1322,11 @@ test('not', () => {
display: flex; display: flex;
} }
.group-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:checked) *) { .group-not-\\[\\:checked\\]\\:flex:is(:where(.group):not(:checked) *) {
display: flex; display: flex;
} }
.group-not-\\[\\:checked\\]\\:flex:is(:where(.group):not(:checked) *) { .group-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:checked) *) {
display: flex; display: flex;
} }
@ -1334,11 +1334,11 @@ test('not', () => {
display: flex; display: flex;
} }
.peer-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):not(:checked) ~ *) { .peer-not-\\[\\:checked\\]\\:flex:is(:where(.peer):not(:checked) ~ *) {
display: flex; display: flex;
} }
.peer-not-\\[\\:checked\\]\\:flex:is(:where(.peer):not(:checked) ~ *) { .peer-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):not(:checked) ~ *) {
display: flex; display: flex;
}" }"
`) `)
@ -1362,11 +1362,11 @@ test('has', () => {
display: flex; display: flex;
} }
.group-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *) { .group-has-\\[\\:checked\\]\\:flex:is(:where(.group):has(:checked) *) {
display: flex; display: flex;
} }
.group-has-\\[\\:checked\\]\\:flex:is(:where(.group):has(:checked) *) { .group-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *) {
display: flex; display: flex;
} }
@ -1374,11 +1374,11 @@ test('has', () => {
display: flex; display: flex;
} }
.peer-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):has(:checked) ~ *) { .peer-has-\\[\\:checked\\]\\:flex:is(:where(.peer):has(:checked) ~ *) {
display: flex; display: flex;
} }
.peer-has-\\[\\:checked\\]\\:flex:is(:where(.peer):has(:checked) ~ *) { .peer-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):has(:checked) ~ *) {
display: flex; display: flex;
} }
@ -1405,15 +1405,7 @@ test('aria', () => {
'peer-aria-checked/parent-name:flex', 'peer-aria-checked/parent-name:flex',
]), ]),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
".group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *) { ".group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *) {
display: flex;
}
.group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *) {
display: flex;
}
.group-aria-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-checked="true"] *) {
display: flex; display: flex;
} }
@ -1421,7 +1413,11 @@ test('aria', () => {
display: flex; display: flex;
} }
.peer-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-modal] ~ *) { .group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *) {
display: flex;
}
.group-aria-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-checked="true"] *) {
display: flex; display: flex;
} }
@ -1429,20 +1425,24 @@ test('aria', () => {
display: flex; display: flex;
} }
.peer-aria-checked\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-checked="true"] ~ *) {
display: flex;
}
.peer-aria-checked\\:flex:is(:where(.peer)[aria-checked="true"] ~ *) { .peer-aria-checked\\:flex:is(:where(.peer)[aria-checked="true"] ~ *) {
display: flex; display: flex;
} }
.aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"] { .peer-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-modal] ~ *) {
display: flex;
}
.peer-aria-checked\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-checked="true"] ~ *) {
display: flex; display: flex;
} }
.aria-checked\\:flex[aria-checked="true"] { .aria-checked\\:flex[aria-checked="true"] {
display: flex; display: flex;
}
.aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"] {
display: flex;
}" }"
`) `)
}) })
@ -1460,15 +1460,11 @@ test('data', () => {
'peer-data-[disabled]/parent-name:flex', 'peer-data-[disabled]/parent-name:flex',
]), ]),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
".group-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-disabled] *) { ".group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *) {
display: flex; display: flex;
} }
.group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *) { .group-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-disabled] *) {
display: flex;
}
.peer-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-disabled] ~ *) {
display: flex; display: flex;
} }
@ -1476,12 +1472,16 @@ test('data', () => {
display: flex; display: flex;
} }
.data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] { .peer-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-disabled] ~ *) {
display: flex; display: flex;
} }
.data-disabled\\:flex[data-disabled] { .data-disabled\\:flex[data-disabled] {
display: flex; display: flex;
}
.data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] {
display: flex;
}" }"
`) `)
}) })
@ -1567,14 +1567,14 @@ test('container queries', () => {
--width-lg: 1024px; --width-lg: 1024px;
} }
@container name (width < 1024px) { @container (width < 1024px) {
.\\@max-lg\\/name\\:flex { .\\@max-lg\\:flex {
display: flex; display: flex;
} }
} }
@container (width < 1024px) { @container name (width < 1024px) {
.\\@max-lg\\:flex { .\\@max-lg\\/name\\:flex {
display: flex; display: flex;
} }
} }
@ -1615,12 +1615,6 @@ test('container queries', () => {
} }
} }
@container name (width >= 1024px) {
.\\@lg\\/name\\:flex {
display: flex;
}
}
@container (width >= 1024px) { @container (width >= 1024px) {
.\\@lg\\:flex { .\\@lg\\:flex {
display: flex; display: flex;
@ -1628,7 +1622,7 @@ test('container queries', () => {
} }
@container name (width >= 1024px) { @container name (width >= 1024px) {
.\\@min-lg\\/name\\:flex { .\\@lg\\/name\\:flex {
display: flex; display: flex;
} }
} }
@ -1637,6 +1631,12 @@ test('container queries', () => {
.\\@min-lg\\:flex { .\\@min-lg\\:flex {
display: flex; display: flex;
} }
}
@container name (width >= 1024px) {
.\\@min-lg\\/name\\:flex {
display: flex;
}
}" }"
`) `)
}) })

View file

@ -94,7 +94,7 @@ export class Variants {
if (z === null) return 1 if (z === null) return 1
if (a.kind === 'arbitrary' && z.kind === 'arbitrary') { if (a.kind === 'arbitrary' && z.kind === 'arbitrary') {
return a.selector.localeCompare(z.selector) return a.selector < z.selector ? -1 : 1
} else if (a.kind === 'arbitrary') { } else if (a.kind === 'arbitrary') {
return 1 return 1
} else if (z.kind === 'arbitrary') { } else if (z.kind === 'arbitrary') {
@ -114,7 +114,7 @@ export class Variants {
let compareFn = this.compareFns.get(aOrder) let compareFn = this.compareFns.get(aOrder)
if (compareFn === undefined) return 0 if (compareFn === undefined) return 0
return compareFn(a, z) return compareFn(a, z) || (a.root < z.root ? -1 : 1)
} }
keys() { keys() {
@ -469,7 +469,7 @@ export function createVariants(theme: Theme): Variants {
let order = let order =
// Compare by bucket name // Compare by bucket name
aBucket.localeCompare(zBucket) || (aBucket === zBucket ? 0 : aBucket < zBucket ? -1 : 1) ||
// If bucket names are the same, compare by value // If bucket names are the same, compare by value
(direction === 'asc' (direction === 'asc'
? parseInt(aValue) - parseInt(zValue) ? parseInt(aValue) - parseInt(zValue)
@ -489,7 +489,7 @@ export function createVariants(theme: Theme): Variants {
// In this scenario, we want to alphabetically sort `calc(100%-1rem)` and // In this scenario, we want to alphabetically sort `calc(100%-1rem)` and
// `calc(100%-2rem)` to make it deterministic. // `calc(100%-2rem)` to make it deterministic.
if (Number.isNaN(order)) { if (Number.isNaN(order)) {
return aValue.localeCompare(zValue) return aValue < zValue ? -1 : 1
} }
return order return order

View file

@ -243,7 +243,7 @@ async function render(page: Page, content: string) {
} }
`, `,
scanFiles([{ content, extension: 'html' }], IO.Sequential | Parsing.Sequential), scanFiles([{ content, extension: 'html' }], IO.Sequential | Parsing.Sequential),
), ).css,
), ),
}) })