From f387d88e46f16f84cf87dba15466a30387d03477 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 16:08:58 -0400 Subject: [PATCH] wip --- packages/@tailwindcss-upgrade/src/migrate.ts | 148 ++++++++++++------ .../@tailwindcss-upgrade/src/stylesheet.ts | 61 +++++--- 2 files changed, 138 insertions(+), 71 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 874c9aa2c..e5e65100d 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -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() - + let stylesheetsById = new Map() 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() + + // 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() + 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 = 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>(() => 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() } diff --git a/packages/@tailwindcss-upgrade/src/stylesheet.ts b/packages/@tailwindcss-upgrade/src/stylesheet.ts index 1030229a1..7f3d2af10 100644 --- a/packages/@tailwindcss-upgrade/src/stylesheet.ts +++ b/packages/@tailwindcss-upgrade/src/stylesheet.ts @@ -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() - importsFromParents = new Set() - importsInSelf = new Set() - hasUtilities = false - - constructor(root: postcss.Root, file?: string) { - this.id = crypto.randomUUID() - this.root = root - this.file = file ?? null - } - - get ancestors() { - return walk(this, (sheet) => sheet.parents ?? []) - } - - get descendants() { - return walk(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() + + this.root.walkAtRules('import', (rule) => { + imports.add(rule) + }) + + return imports + } + + get isEmpty() { + return this.root.toString().trim() === '' + } + + get ancestors() { + return walk(this, (sheet) => sheet.parents ?? []) + } + + get descendants() { + return walk(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(value: T, getList: (value: T) => Iterable, seen = new Set()): Iterable {