Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for new loadModule and loadStylesheet APIs from v4 #317

Merged
merged 3 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"ast-types": "^0.14.2",
"clear-module": "^4.1.2",
"cpy-cli": "^5.0.0",
"enhanced-resolve": "^5.17.1",
"esbuild": "^0.19.8",
"escalade": "^3.1.1",
"import-sort-style-module": "^6.0.0",
Expand Down
92 changes: 75 additions & 17 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// @ts-check
import * as fs from 'fs/promises'
import { createRequire } from 'module'
import * as path from 'path'
import { pathToFileURL } from 'url'
import clearModule from 'clear-module'
Expand All @@ -18,11 +17,9 @@ import loadConfigFallback from 'tailwindcss/loadConfig'
import resolveConfigFallback from 'tailwindcss/resolveConfig'
import type { RequiredConfig } from 'tailwindcss/types/config.js'
import { expiringMap } from './expiring-map.js'
import { resolveFrom, resolveIn } from './resolve'
import { resolveCssFrom, resolveJsFrom } from './resolve'
import type { ContextContainer } from './types'

let localRequire = createRequire(import.meta.url)

let sourceToPathMap = new Map<string, string | null>()
let sourceToEntryMap = new Map<string, string | null>()
let pathToContextMap = expiringMap<string | null, ContextContainer>(10_000)
Expand Down Expand Up @@ -107,7 +104,7 @@ async function loadTailwindConfig(
let tailwindConfig: RequiredConfig = { content: [] }

try {
let pkgFile = resolveIn('tailwindcss/package.json', [baseDir])
let pkgFile = resolveJsFrom(baseDir, 'tailwindcss/package.json')
let pkgDir = path.dirname(pkgFile)

try {
Expand Down Expand Up @@ -151,29 +148,40 @@ async function loadTailwindConfig(
* Create a loader function that can load plugins and config files relative to
* the CSS file that uses them. However, we don't want missing files to prevent
* everything from working so we'll let the error handler decide how to proceed.
*
* @param {object} param0
* @returns
*/
function createLoader<T>({
legacy,
filepath,
onError,
}: {
legacy: boolean
filepath: string
onError: (id: string, error: unknown) => T
onError: (id: string, error: unknown, resourceType: string) => T
}) {
let baseDir = path.dirname(filepath)
let cacheKey = `${+Date.now()}`

return async function loadFile(id: string) {
async function loadFile(id: string, base: string, resourceType: string) {
try {
let resolved = resolveFrom(baseDir, id)
let resolved = resolveJsFrom(base, id)

let url = pathToFileURL(resolved)
url.searchParams.append('t', cacheKey)

return await import(url.href).then((m) => m.default ?? m)
} catch (err) {
return onError(id, err)
return onError(id, err, resourceType)
}
}

if (legacy) {
let baseDir = path.dirname(filepath)
return (id: string) => loadFile(id, baseDir, 'module')
}

return async (id: string, base: string, resourceType: string) => {
return {
base,
module: await loadFile(id, base, resourceType),
}
}
}
Expand All @@ -184,7 +192,8 @@ async function loadV4(
entryPoint: string | null,
) {
// Import Tailwind — if this is v4 it'll have APIs we can use directly
let pkgPath = resolveIn('tailwindcss', [baseDir])
let pkgPath = resolveJsFrom(baseDir, 'tailwindcss')

let tw = await import(pathToFileURL(pkgPath).toString())

// This is not Tailwind v4
Expand All @@ -195,15 +204,63 @@ async function loadV4(
// If the user doesn't define an entrypoint then we use the default theme
entryPoint = entryPoint ?? `${pkgDir}/theme.css`

let importBasePath = path.dirname(entryPoint)

// Resolve imports in the entrypoint to a flat CSS tree
let css = await fs.readFile(entryPoint, 'utf-8')
let resolveImports = postcss([postcssImport()])
let result = await resolveImports.process(css, { from: entryPoint })

// Determine if the v4 API supports resolving `@import`
let supportsImports = false
try {
await tw.__unstable__loadDesignSystem('@import "./empty";', {
loadStylesheet: () => {
supportsImports = true
return {
base: importBasePath,
content: '',
}
},
})
} catch {}

if (!supportsImports) {
let resolveImports = postcss([postcssImport()])
let result = await resolveImports.process(css, { from: entryPoint })
css = result.css
}
Comment on lines +226 to +230
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice that we can already shave off some work here too 💪


// Load the design system and set up a compatible context object that is
// usable by the rest of the plugin
let design = await tw.__unstable__loadDesignSystem(result.css, {
let design = await tw.__unstable__loadDesignSystem(css, {
base: importBasePath,

// v4.0.0-alpha.25+
loadModule: createLoader({
legacy: false,
filepath: entryPoint,
onError: (id, err, resourceType) => {
console.error(`Unable to load ${resourceType}: ${id}`, err)

if (resourceType === 'config') {
return {}
} else if (resourceType === 'plugin') {
return () => {}
}
},
}),

loadStylesheet: async (id: string, base: string) => {
let resolved = resolveCssFrom(base, id)

return {
base: path.dirname(resolved),
content: await fs.readFile(resolved, 'utf-8'),
}
},

// v4.0.0-alpha.24 and below
loadPlugin: createLoader({
legacy: true,
filepath: entryPoint,
onError(id, err) {
console.error(`Unable to load plugin: ${id}`, err)
Expand All @@ -213,6 +270,7 @@ async function loadV4(
}),

loadConfig: createLoader({
legacy: true,
filepath: entryPoint,
onError(id, err) {
console.error(`Unable to load config: ${id}`, err)
Expand Down
49 changes: 35 additions & 14 deletions src/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import { createRequire as req } from 'node:module'
import resolveFrom from 'resolve-from'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { CachedInputFileSystem, ResolverFactory } from 'enhanced-resolve'
import { expiringMap } from './expiring-map'

const localRequire = req(import.meta.url)
const fileSystem = new CachedInputFileSystem(fs, 30_000)

const esmResolver = ResolverFactory.createResolver({
fileSystem,
useSyncFileSystemCalls: true,
extensions: ['.mjs', '.js'],
mainFields: ['module'],
conditionNames: ['node', 'import'],
})

const cjsResolver = ResolverFactory.createResolver({
fileSystem,
useSyncFileSystemCalls: true,
extensions: ['.js', '.cjs'],
mainFields: ['main'],
conditionNames: ['node', 'require'],
})

const cssResolver = ResolverFactory.createResolver({
fileSystem,
useSyncFileSystemCalls: true,
extensions: ['.css'],
mainFields: ['style'],
conditionNames: ['style'],
})

// This is a long-lived cache for resolved modules whether they exist or not
// Because we're compatible with a large number of plugins, we need to check
Expand All @@ -11,17 +36,11 @@ const localRequire = req(import.meta.url)
// failed module resolutions making repeated checks very expensive.
const resolveCache = expiringMap<string, string | null>(30_000)

export function resolveIn(id: string, paths: string[]) {
return localRequire.resolve(id, {
paths,
})
}

export function maybeResolve(name: string) {
let modpath = resolveCache.get(name)

if (modpath === undefined) {
modpath = freshMaybeResolve(name)
modpath = resolveJsFrom(fileURLToPath(import.meta.url), name)
resolveCache.set(name, modpath)
}

Expand All @@ -39,12 +58,14 @@ export async function loadIfExists<T>(name: string): Promise<T | null> {
return null
}

function freshMaybeResolve(name: string) {
export function resolveJsFrom(base: string, id: string): string {
try {
return localRequire.resolve(name)
return esmResolver.resolveSync({}, base, id) || id
} catch (err) {
return null
return cjsResolver.resolveSync({}, base, id) || id
}
}

export { resolveFrom }
export function resolveCssFrom(base: string, id: string) {
return cssResolver.resolveSync({}, base, id) || id
}
Loading