This commit is contained in:
Jordan Pittman 2024-10-04 16:08:58 -04:00
parent 0bb3e0863d
commit f387d88e46
2 changed files with 138 additions and 71 deletions

View file

@ -2,12 +2,13 @@ import path from 'node:path'
import postcss from 'postcss'
import type { Config } from 'tailwindcss'
import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { DefaultMap } from '../../tailwindcss/src/utils/default-map'
import { segment } from '../../tailwindcss/src/utils/segment'
import { migrateAtApply } from './codemods/migrate-at-apply'
import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities'
import { migrateMissingLayers } from './codemods/migrate-missing-layers'
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
import { Stylesheet } from './stylesheet'
import { Stylesheet, type StylesheetId } from './stylesheet'
import { resolveCssId } from './utils/resolve'
import { walk, WalkAction } from './utils/walk'
@ -85,21 +86,19 @@ export async function analyze(stylesheets: Stylesheet[]) {
// that we don't want to modify
if (!stylesheet) return
// If it does then this import node get added to that sylesheets `importsFromParents` set
let parent = stylesheetsByFile.get(node.source?.input.file ?? '')
if (!parent) return
// Mark the import node with the ID of the stylesheet it points to
// We will use these later to build lookup tables and modify the AST
node.raws.tailwind_destination_sheet_id = stylesheet.id
// Record the import node for this sheet so it can be modified later
stylesheet.importsFromParents.add(node)
parent.importsInSelf.add(node)
let parent = node.source?.input.file
? stylesheetsByFile.get(node.source.input.file)
: undefined
// Connect all stylesheets together in a dependency graph
// The way this works is it uses the knowledge that we have a list of
// the `@import` nodes that cause a given stylesheet to be imported.
// That import has a `source` pointing to parent stylesheet's file path
// which can be used to look it up
stylesheet.parents.add(parent)
parent.children.add(stylesheet)
// Connect sheets together in a dependency graph
if (parent) {
stylesheet.parents.add(parent)
parent.children.add(stylesheet)
}
for (let part of segment(node.params, ' ')) {
if (!part.startsWith('layer(')) continue
@ -129,42 +128,46 @@ export async function analyze(stylesheets: Stylesheet[]) {
}
export async function split(stylesheets: Stylesheet[]) {
let utilitySheets = new Map<Stylesheet, Stylesheet>()
let stylesheetsById = new Map<StylesheetId, Stylesheet>()
for (let sheet of stylesheets) {
if (!sheet.file) continue
stylesheetsById.set(sheet.id, sheet)
}
// We only care about stylesheets that were imported into a layer e.g. `layer(utilities)`
// A list of stylesheets we've created specifically to hold `@utility` rules
let containsUtilities = new Set<Stylesheet>()
// Keep track of sheets that contain `@utillity` rules
for (let sheet of stylesheets) {
let isLayered = sheet.layers.has('utilities') || sheet.layers.has('components')
if (!isLayered) continue
// We only care about stylesheets that contain an `@utility`
let hasUtilities = false
walk(sheet.root, (node) => {
if (node.type !== 'atrule') return
if (node.name !== 'utility') return
hasUtilities = true
containsUtilities.add(sheet)
return WalkAction.Stop
})
sheet.hasUtilities = hasUtilities
}
// Split every imported stylesheet into two parts
// - one with the utilities and one without
let utilitySheets = new Map<Stylesheet, Stylesheet>()
for (let sheet of stylesheets) {
if (!sheet.importsFromParents.size) continue
// Ignore stylesheets that were not imported
if (!sheet.file) continue
if (sheet.parents.size === 0) continue
// Skip stylesheets that don't have utilities
// and don't have any children that have utilities
if (!sheet.hasUtilities) {
if (!Array.from(sheet.descendants).some((child) => child.hasUtilities)) {
if (!containsUtilities.has(sheet)) {
if (!Array.from(sheet.descendants).some((child) => containsUtilities.has(child))) {
continue
}
}
// Split the stylesheet into two parts: one with the utilities and one without
let utilities = postcss.root({
raws: {
tailwind_pretty: true,
@ -175,6 +178,8 @@ export async function split(stylesheets: Stylesheet[]) {
if (node.type !== 'atrule') return
if (node.name !== 'utility') return
// `append` will move this node from the original sheet
// to the new utilities sheet
utilities.append(node)
return WalkAction.Skip
@ -182,24 +187,37 @@ export async function split(stylesheets: Stylesheet[]) {
let utilitySheet = await Stylesheet.fromRoot(
utilities,
sheet.file!.replace(/\.css$/, '.utilities.css'),
sheet.file.replace(/\.css$/, '.utilities.css'),
)
utilitySheets.set(sheet, utilitySheet)
stylesheetsById.set(utilitySheet.id, utilitySheet)
}
for (let sheet of stylesheets) {
let utilitySheet = utilitySheets.get(sheet)
let utilityImports: Set<postcss.AtRule> = new Set()
console.log(`---- ${sheet.file} ----`)
console.log(Array.from(sheet.importsInSelf).map((node) => node.toString()))
for (let node of sheet.importRules) {
let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined
// This import rule does not point to a stylesheet
// which likely means it points to `node_modules`
if (!sheetId) continue
// This import points to a stylesheet that no longer exists
// which likely means it was removed by the optimizer
// this will be cleaned up later
let originalDestination = stylesheetsById.get(sheetId)
if (!originalDestination) continue
for (let node of sheet.importsInSelf) {
let id = node.params.match(/['"](.*)['"]/)?.[1]
if (!id) return
let newFile = id.replace(/\.css$/, '.utilities.css')
// The import will just point to the new file without any media queries,
// layers, or other conditions because `@utility` MUST be top-level.
let newImport = node.clone({
params: `"${newFile}"`,
raws: {
@ -208,25 +226,29 @@ export async function split(stylesheets: Stylesheet[]) {
})
if (utilitySheet) {
// If this import is intended to go into the utility sheet
// we'll collect it into a list to add later. If we don't'
// we'll end up adding them in reverse order.
utilityImports.add(newImport)
utilitySheet.importsInSelf.add(newImport)
for (let child of sheet.children) {
if (child.importsFromParents.has(node)) {
utilitySheets.get(child)!.importsFromParents.add(newImport)
}
}
let utilityDestination = utilitySheets.get(originalDestination)
if (!utilityDestination) continue
newImport.raws.tailwind_destination_sheet_id = utilityDestination.id
} else {
// This import will go immediately after the original import
node.after(newImport)
newImport.raws.tailwind_destination_sheet_id = node.raws.tailwind_destination_sheet_id
}
}
// Add imports to the top of the utility sheet if necessary
if (utilitySheet && utilityImports.size > 0) {
utilitySheet.root.prepend(Array.from(utilityImports))
}
}
// Make sure the utility sheets track parents and import nodes and what not
// Make sure the utility sheets are linked to one another
for (let [normalSheet, utilitySheet] of utilitySheets) {
for (let parent of normalSheet.parents) {
let utilityParent = utilitySheets.get(parent)
@ -241,32 +263,54 @@ export async function split(stylesheets: Stylesheet[]) {
}
}
// At this point, we probably created `{name}.utilities.css` files. If the
// original `{name}.css` is empty, then we can optimize the output a bit more
// by re-using the original file but just getting rid of the `layer
// (utilities)` marker.
// If removing files means that some `@import` at-rules are now unnecessary, we
// can also remove those.
// Tracks the at rules that import a given stylesheet
let importNodes = new DefaultMap<Stylesheet, Set<postcss.AtRule>>(() => new Set())
for (let sheet of stylesheetsById.values()) {
for (let node of sheet.importRules) {
let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined
// This import rule does not point to a stylesheet
if (!sheetId) continue
let destination = stylesheetsById.get(sheetId)
// This import rule does not point to a stylesheet that exists
// We'll remove it later
if (!destination) continue
importNodes.get(destination).add(node)
}
}
// At this point we've created many `{name}.utilities.css` files.
// If the original file _becomes_ empty after splitting that means that
// dedicated utility file is not required and we can move the utilities
// back to the original file.
//
// This could be done in one step but separating them makes it easier to
// reason about since the stylesheets are in a consistent state before we
// perform any cleanup tasks.
for (let sheet of stylesheets) {
continue
let utilitySheet = utilitySheets.get(sheet)
// This sheet was not split so there's nothing to do
if (!utilitySheet) continue
if (sheet.root.toString().trim() !== '') continue
// This sheet did not become empty
if (!sheet.isEmpty) continue
// We have a sheet that became empty after splitting
// 1. Replace the sheet with it's utility sheet content
sheet.root = utilitySheet.root
// 2. Point the imports back to the original file since we don't need the utility file anymore
for (let node of utilitySheet.importsFromParents) {
// 2. Rewrite imports in parent sheets to point to the original sheet
for (let node of importNodes.get(utilitySheet)) {
node.params = node.params.replace(/\.utilities\.css['"]/, '.css')
}
// 3. Remove the original import from the non-utility sheet
// TODO: This does not work because we're cloning trees during the migration
// we *cannot* rely on reference semantics at all for any postcss nodes
for (let node of sheet.importsFromParents) {
for (let node of importNodes.get(sheet)) {
node.remove()
}

View file

@ -1,14 +1,17 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as util from 'node:util'
import * as postcss from 'postcss'
export type StylesheetId = string
export class Stylesheet {
/**
* A unique identifier for this stylesheet
*
* Used to track the stylesheet in PostCSS nodes.
*/
id: string
id: StylesheetId
/**
* The PostCSS AST that represents this stylesheet.
@ -37,24 +40,6 @@ export class Stylesheet {
*/
layers = new Set<string>()
importsFromParents = new Set<postcss.AtRule>()
importsInSelf = new Set<postcss.AtRule>()
hasUtilities = false
constructor(root: postcss.Root, file?: string) {
this.id = crypto.randomUUID()
this.root = root
this.file = file ?? null
}
get ancestors() {
return walk<Stylesheet>(this, (sheet) => sheet.parents ?? [])
}
get descendants() {
return walk<Stylesheet>(this, (sheet) => sheet.children ?? [])
}
static async load(filepath: string) {
filepath = path.resolve(process.cwd(), filepath)
@ -73,6 +58,44 @@ export class Stylesheet {
static async fromRoot(root: postcss.Root, file?: string) {
return new Stylesheet(root, file)
}
constructor(root: postcss.Root, file?: string) {
this.id = Math.random().toString(36).slice(2)
this.root = root
this.file = file ?? null
}
get importRules() {
let imports = new Set<postcss.AtRule>()
this.root.walkAtRules('import', (rule) => {
imports.add(rule)
})
return imports
}
get isEmpty() {
return this.root.toString().trim() === ''
}
get ancestors() {
return walk<Stylesheet>(this, (sheet) => sheet.parents ?? [])
}
get descendants() {
return walk<Stylesheet>(this, (sheet) => sheet.children ?? [])
}
[util.inspect.custom]() {
return {
...this,
root: this.root.toString(),
layers: Array.from(this.layers),
parents: Array.from(this.parents, (s) => s.id),
children: Array.from(this.children, (s) => s.id),
}
}
}
function* walk<T>(value: T, getList: (value: T) => Iterable<T>, seen = new Set<T>()): Iterable<T> {