Skip to content
Prev Previous commit
Next Next commit
Handle reference mode for plugins that register base styles
  • Loading branch information
philipp-spiess committed Dec 3, 2024
commit 265d5a9e00af800165762c3dea1949f5692134ee
12 changes: 6 additions & 6 deletions packages/tailwindcss/src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type Comment = {

export type Context = {
kind: 'context'
context: Record<string, string>
context: Record<string, string | boolean>
nodes: AstNode[]
}

Expand Down Expand Up @@ -82,7 +82,7 @@ export function comment(value: string): Comment {
}
}

export function context(context: Record<string, string>, nodes: AstNode[]): Context {
export function context(context: Record<string, string | boolean>, nodes: AstNode[]): Context {
return {
kind: 'context',
context,
Expand Down Expand Up @@ -115,12 +115,12 @@ export function walk(
utils: {
parent: AstNode | null
replaceWith(newNode: AstNode | AstNode[]): void
context: Record<string, string>
context: Record<string, string | boolean>
path: AstNode[]
},
) => void | WalkAction,
parentPath: AstNode[] = [],
context: Record<string, string> = {},
context: Record<string, string | boolean> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
Expand Down Expand Up @@ -175,12 +175,12 @@ export function walkDepth(
utils: {
parent: AstNode | null
path: AstNode[]
context: Record<string, string>
context: Record<string, string | boolean>
replaceWith(newNode: AstNode[]): void
},
) => void,
parentPath: AstNode[] = [],
context: Record<string, string> = {},
context: Record<string, string | boolean> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
Expand Down
32 changes: 19 additions & 13 deletions packages/tailwindcss/src/at-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,28 @@ test('can resolve relative @imports', async () => {
`)
})

test('can resolve @imports as reference', async () => {
test('recursively removes style rules for `@import "…" reference`', async () => {
let loadStylesheet = async (id: string, base: string) => {
expect(base).toBe('/root')
expect(id).toBe('./foo/bar.css')
if (id === './foo/baz.css') {
return {
content: css`
.foo {
color: red;
}
@utility foo {
color: red;
}
@theme {
--breakpoint-md: 768px;
}
@variant hocus (&:hover, &:focus);
`,
base: '/root/foo',
}
}
return {
content: css`
.foo {
color: red;
}
@utility foo {
color: red;
}
@theme {
--breakpoint-md: 768px;
}
@variant hocus (&:hover, &:focus);
@import './foo/baz.css';
`,
base: '/root/foo',
}
Expand Down
41 changes: 21 additions & 20 deletions packages/tailwindcss/src/at-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,19 @@ export async function substituteAtImports(
base: string,
loadStylesheet: LoadStylesheet,
recurseCount = 0,
mode: 'normal' | 'reference' = 'normal',
) {
let features = Features.None
let promises: Promise<void>[] = []

walk(ast, (node, { replaceWith }) => {
walk(ast, (node, { replaceWith, context: { reference } }) => {
if (node.kind === 'at-rule' && node.name === '@import') {
let parsed = parseImportParams(ValueParser.parse(node.params))
if (parsed === null) return

features |= Features.AtImport

if (parsed.media) {
let flags = segment(parsed.media, ' ')

if (flags.includes('reference')) {
parsed.media = flags.filter((flag) => flag !== 'reference').join(' ')
mode = 'reference'
}

if (parsed.media === '') {
parsed.media = null
}
}

let { uri, layer, media, supports } = parsed
reference ||= parsed.reference ?? false

// Skip importing data or remote URIs
if (uri.startsWith('data:')) return
Expand All @@ -59,18 +46,21 @@ export async function substituteAtImports(
let loaded = await loadStylesheet(uri, base)
let ast = CSS.parse(loaded.content)

if (mode === 'reference') {
if (reference) {
ast = stripStyleRules(ast)
}

await substituteAtImports(ast, loaded.base, loadStylesheet, recurseCount + 1, mode)

contextNode.nodes = buildImportNodes(
ast = buildImportNodes(
[context({ base: loaded.base }, ast)],
layer,
media,
supports,
!!reference,
)

await substituteAtImports(ast, loaded.base, loadStylesheet, recurseCount + 1)

contextNode.nodes = ast
})(),
)

Expand Down Expand Up @@ -98,6 +88,7 @@ export function parseImportParams(params: ValueParser.ValueAstNode[]) {
let layer: string | null = null
let media: string | null = null
let supports: string | null = null
let reference: true | null = null

for (let i = 0; i < params.length; i++) {
let node = params[i]
Expand Down Expand Up @@ -147,20 +138,26 @@ export function parseImportParams(params: ValueParser.ValueAstNode[]) {
continue
}

if (node.kind === 'word' && node.value.toLocaleLowerCase() === 'reference') {
reference = true
continue
}

media = ValueParser.toCss(params.slice(i))
break
}

if (!uri) return null

return { uri, layer, media, supports }
return { uri, layer, media, supports, reference }
}

function buildImportNodes(
importedAst: AstNode[],
layer: string | null,
media: string | null,
supports: string | null,
reference: boolean | null,
): AstNode[] {
let root = importedAst

Expand All @@ -176,6 +173,10 @@ function buildImportNodes(
root = [atRule('@supports', supports[0] === '(' ? supports : `(${supports})`, root)]
}

if (reference !== null) {
root = [context({ reference }, root)]
}

return root
}

Expand Down
44 changes: 32 additions & 12 deletions packages/tailwindcss/src/compat/apply-compat-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ export async function applyCompatibilityHooks({
globs: { origin?: string; pattern: string }[]
}) {
let features = Features.None
let pluginPaths: [{ id: string; base: string }, CssPluginOptions | null][] = []
let configPaths: { id: string; base: string }[] = []
let pluginPaths: [{ id: string; base: string; reference: boolean }, CssPluginOptions | null][] =
[]
let configPaths: { id: string; base: string; reference: boolean }[] = []

walk(ast, (node, { parent, replaceWith, context }) => {
if (node.kind !== 'at-rule') return
Expand Down Expand Up @@ -95,7 +96,7 @@ export async function applyCompatibilityHooks({
}

pluginPaths.push([
{ id: pluginPath, base: context.base },
{ id: pluginPath, base: context.base as string, reference: !!context.reference },
Object.keys(options).length > 0 ? options : null,
])

Expand All @@ -114,7 +115,11 @@ export async function applyCompatibilityHooks({
throw new Error('`@config` cannot be nested.')
}

configPaths.push({ id: node.params.slice(1, -1), base: context.base })
configPaths.push({
id: node.params.slice(1, -1),
base: context.base as string,
reference: !!context.reference,
})
replaceWith([])
features |= Features.JsPluginCompat
return
Expand Down Expand Up @@ -153,23 +158,25 @@ export async function applyCompatibilityHooks({

let [configs, pluginDetails] = await Promise.all([
Promise.all(
configPaths.map(async ({ id, base }) => {
configPaths.map(async ({ id, base, reference }) => {
let loaded = await loadModule(id, base, 'config')
return {
path: id,
base: loaded.base,
config: loaded.module as UserConfig,
reference,
}
}),
),
Promise.all(
pluginPaths.map(async ([{ id, base }, pluginOptions]) => {
pluginPaths.map(async ([{ id, base, reference }, pluginOptions]) => {
let loaded = await loadModule(id, base, 'plugin')
return {
path: id,
base: loaded.base,
plugin: loaded.module as Plugin,
options: pluginOptions,
reference,
}
}),
),
Expand Down Expand Up @@ -203,22 +210,32 @@ function upgradeToFullPluginSupport({
path: string
base: string
config: UserConfig
reference: boolean
}[]
pluginDetails: {
path: string
base: string
plugin: Plugin
options: CssPluginOptions | null
reference: boolean
}[]
}) {
let features = Features.None
let pluginConfigs = pluginDetails.map((detail) => {
if (!detail.options) {
return { config: { plugins: [detail.plugin] }, base: detail.base }
return {
config: { plugins: [detail.plugin] },
base: detail.base,
reference: detail.reference,
}
}

if ('__isOptionsFunction' in detail.plugin) {
return { config: { plugins: [detail.plugin(detail.options)] }, base: detail.base }
return {
config: { plugins: [detail.plugin(detail.options)] },
base: detail.base,
reference: detail.reference,
}
}

throw new Error(`The plugin "${detail.path}" does not accept options`)
Expand All @@ -227,9 +244,9 @@ function upgradeToFullPluginSupport({
let userConfig = [...pluginConfigs, ...configs]

let { resolvedConfig } = resolveConfig(designSystem, [
{ config: createCompatConfig(designSystem.theme), base },
{ config: createCompatConfig(designSystem.theme), base, reference: true },
...userConfig,
{ config: { plugins: [darkModePlugin] }, base },
{ config: { plugins: [darkModePlugin] }, base, reference: true },
])
let { resolvedConfig: resolvedUserConfig, replacedThemeKeys } = resolveConfig(
designSystem,
Expand All @@ -242,8 +259,11 @@ function upgradeToFullPluginSupport({
},
})

for (let { handler } of resolvedConfig.plugins) {
handler(pluginApi)
for (let {
plugin: { handler },
reference,
} of resolvedConfig.plugins) {
handler(reference ? { ...pluginApi, addBase: () => {} } : pluginApi)
}

// Merge the user-configured theme keys into the design system. The compat
Expand Down
22 changes: 13 additions & 9 deletions packages/tailwindcss/src/compat/config/resolve-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ export interface ConfigFile {
path?: string
base: string
config: UserConfig
reference: boolean
}

interface ResolutionContext {
design: DesignSystem
configs: UserConfig[]
plugins: PluginWithConfig[]
plugins: { plugin: PluginWithConfig; reference: boolean }[]
content: ResolvedContentConfig
theme: Record<string, ThemeValue>
extend: Record<string, ThemeValue[]>
Expand Down Expand Up @@ -128,25 +129,28 @@ export type PluginUtils = {
colors: typeof colors
}

function extractConfigs(ctx: ResolutionContext, { config, base, path }: ConfigFile): void {
let plugins: PluginWithConfig[] = []
function extractConfigs(
ctx: ResolutionContext,
{ config, base, path, reference }: ConfigFile,
): void {
let plugins: { plugin: PluginWithConfig; reference: boolean }[] = []

// Normalize plugins so they share the same shape
for (let plugin of config.plugins ?? []) {
if ('__isOptionsFunction' in plugin) {
// Happens with `plugin.withOptions()` when no options were passed:
// e.g. `require("my-plugin")` instead of `require("my-plugin")(options)`
plugins.push(plugin())
plugins.push({ plugin: plugin(), reference })
} else if ('handler' in plugin) {
// Happens with `plugin(…)`:
// e.g. `require("my-plugin")`
//
// or with `plugin.withOptions()` when the user passed options:
// e.g. `require("my-plugin")(options)`
plugins.push(plugin)
plugins.push({ plugin, reference })
} else {
// Just a plain function without using the plugin(…) API
plugins.push({ handler: plugin })
plugins.push({ plugin: { handler: plugin }, reference })
}
}

Expand All @@ -158,15 +162,15 @@ function extractConfigs(ctx: ResolutionContext, { config, base, path }: ConfigFi
}

for (let preset of config.presets ?? []) {
extractConfigs(ctx, { path, base, config: preset })
extractConfigs(ctx, { path, base, config: preset, reference })
}

// Apply configs from plugins
for (let plugin of plugins) {
ctx.plugins.push(plugin)

if (plugin.config) {
extractConfigs(ctx, { path, base, config: plugin.config })
if (plugin.plugin.config) {
extractConfigs(ctx, { path, base, config: plugin.plugin.config, reference })
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/tailwindcss/src/compat/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type ThemeConfig = Record<string, ThemeValue> & {

export interface ResolvedConfig {
theme: Record<string, Record<string, unknown>>
plugins: PluginWithConfig[]
Copy link
Contributor

Choose a reason for hiding this comment

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

This one feels wrong to change. In theory this type could be exposed to the user (it might be with the config() function actually — that works in v4 right?)

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah yep you are right this is currently exposed and this does break user space. Need to make it even uglier and emit this as a separate Set<> like you suggested via DM. 😐

Copy link
Member Author

Choose a reason for hiding this comment

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

We could also potentially put it into PluginWithConfig since that is already an object with a handler function in v3, adding a new property there would not be an issue I think then. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah lets do that

plugins: { plugin: PluginWithConfig; reference: boolean }[]
}

// Content support
Expand Down
Loading