This commit is contained in:
Pouya Saadeghi 2026-09-26 08:50:30 -04:00 • committed by GitHub
commit 65172840a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 328 additions and 1 deletions

View file

@ -158,6 +158,7 @@ async function parseCss(
let customVariants = new Map<string, (designSystem: DesignSystem) => void>()
let customVariantDependencies = new Map<string, Set<string>>()
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let importedUtilityRootsByPrefix = new Map<string, Set<string>>()
let firstThemeRule = null as StyleRule | null
let utilitiesNode = null as AtRule | null
let variantNodes: AtRule[] = []
@ -233,6 +234,19 @@ async function parseCss(
}
let utility = createCssUtility(node)
let importPrefix = ctx.context.importPrefix as string | undefined
if (importPrefix) {
let prefixedParams = `${importPrefix}-${node.params}`
utility = createCssUtility({ ...node, params: prefixedParams })
if (utility !== null) {
let roots = importedUtilityRootsByPrefix.get(importPrefix)
if (!roots) importedUtilityRootsByPrefix.set(importPrefix, (roots = new Set()))
let root = unescape(prefixedParams)
roots.add(root.endsWith('-*') ? root.slice(0, -2) : root)
}
}
if (utility === null) {
if (!node.params.endsWith('-*')) {
if (node.params.endsWith('*')) {
@ -509,13 +523,36 @@ async function parseCss(
else if (param.startsWith('prefix(')) {
let prefix = param.slice(7, -1)
if (!IS_VALID_PREFIX.test(prefix)) {
throw new Error(
`The prefix "${prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.`,
)
}
let containsTailwindUtilities = false
let themeNodes: AtRule[] = []
walk(node.nodes, (child) => {
if (child.kind !== 'at-rule') return
if (child.name === '@tailwind' && child.params.startsWith('utilities')) {
containsTailwindUtilities = true
}
if (child.name === '@theme') {
child.params += ` prefix(${prefix})`
themeNodes.push(child)
return WalkAction.Skip
}
})
if (containsTailwindUtilities) {
theme.prefix = prefix
for (let themeNode of themeNodes) {
themeNode.params += ` prefix(${prefix})`
}
}
node.nodes = [contextNode({ importPrefix: prefix }, node.nodes)]
}
// Handle important
@ -602,6 +639,49 @@ async function parseCss(
let designSystem = buildDesignSystem(theme, utilitiesNode?.src)
if (importedUtilityRootsByPrefix.size > 0) {
let parseCandidate = designSystem.parseCandidate
let importedUtilityRoots = new Set<string>()
for (let roots of importedUtilityRootsByPrefix.values()) {
for (let root of roots) importedUtilityRoots.add(root)
}
function parseCandidateWithoutImportedRoots(candidate: string) {
return parseCandidate(candidate).filter((parsedCandidate) => {
return (
parsedCandidate.kind === 'arbitrary' || !importedUtilityRoots.has(parsedCandidate.root)
)
})
}
designSystem.parseCandidate = (candidate) => {
let [prefix, ...parts] = segment(candidate, ':')
let roots = importedUtilityRootsByPrefix.get(prefix)
if (!roots) return parseCandidateWithoutImportedRoots(candidate)
let base = parts.pop()
if (!base) return []
// Imported utilities use `prefix:name` publicly, but are registered as
// `prefix-name` internally because `@utility` names cannot contain `:`.
let internalCandidate = [...parts, `${prefix}-${base}`].join(':')
if (designSystem.theme.prefix) {
internalCandidate = `${designSystem.theme.prefix}:${internalCandidate}`
}
let results = parseCandidate(internalCandidate)
.filter((parsedCandidate) => {
return parsedCandidate.kind !== 'arbitrary' && roots.has(parsedCandidate.root)
})
.map((parsedCandidate) => ({
...parsedCandidate,
raw: candidate,
}))
return results.length > 0 ? results : parseCandidateWithoutImportedRoots(candidate)
}
}
if (important) {
designSystem.important = important
}

View file

@ -311,6 +311,253 @@ test('a prefix can be configured via @import prefix(…)', async () => {
expect(await run(['underline', 'hover:line-through', 'custom'], input, options)).toEqual('')
})
test('custom utilities from imports use the Tailwind import prefix', async () => {
let input = css`
@import 'tailwindcss' prefix(tw);
@import './components.css';
`
async function loadStylesheet(id: string, base: string) {
return {
path: '',
base,
content:
id === 'tailwindcss'
? css`
@tailwind utilities;
`
: css`
@utility card {
color: red;
}
`,
}
}
expect(await run(['underline', 'card'], input, { loadStylesheet })).toEqual('')
expect(await run(['tw:underline', 'tw:card'], input, { loadStylesheet })).toMatchInlineSnapshot(`
"
.tw\\:card {
color: red;
}
.tw\\:underline {
text-decoration-line: underline;
}
"
`)
})
test('custom utilities from prefixed imports use their import prefix', async () => {
let input = css`
@import 'tailwindcss' prefix(tw);
@import './components.css' prefix(ui);
@utility ui-underline {
color: red;
}
`
async function loadStylesheet(id: string, base: string) {
return {
path: '',
base,
content:
id === 'tailwindcss'
? css`
@tailwind utilities;
`
: css`
@utility card {
color: red;
}
`,
}
}
expect(
await run(['underline', 'card', 'tw:card', 'ui:underline', 'tw:ui-card'], input, {
loadStylesheet,
}),
).toEqual('')
expect(await run(['tw:underline', 'ui:card', 'tw:ui-underline'], input, { loadStylesheet }))
.toMatchInlineSnapshot(`
"
.tw\\:ui-underline, .ui\\:card {
color: red;
}
.tw\\:underline {
text-decoration-line: underline;
}
"
`)
})
test('a prefixed custom import does not prefix Tailwind utilities', async () => {
let input = css`
@import 'tailwindcss';
@import './components.css' prefix(ui);
`
async function loadStylesheet(id: string, base: string) {
return {
path: '',
base,
content:
id === 'tailwindcss'
? css`
@tailwind utilities;
`
: css`
@utility card {
color: red;
}
`,
}
}
expect(await run(['card', 'ui-card', 'ui:underline'], input, { loadStylesheet })).toEqual('')
expect(await run(['underline', 'ui:card'], input, { loadStylesheet })).toMatchInlineSnapshot(`
"
.ui\\:card {
color: red;
}
.underline {
text-decoration-line: underline;
}
"
`)
})
test('custom utilities from prefixed imports support variants', async () => {
let input = css`
@import 'tailwindcss';
@import './components.css' prefix(ui);
`
async function loadStylesheet(id: string, base: string) {
return {
path: '',
base,
content:
id === 'tailwindcss'
? css`
@tailwind utilities;
`
: css`
@utility card {
color: red;
}
`,
}
}
expect(await run(['hover:ui:card'], input, { loadStylesheet })).toEqual('')
expect(await run(['ui:hover:card'], input, { loadStylesheet })).toMatchInlineSnapshot(`
"
@media (hover: hover) {
.ui\\:hover\\:card:hover {
color: red;
}
}
"
`)
})
test('a prefixed custom import with a theme does not prefix Tailwind utilities', async () => {
let input = css`
@import 'tailwindcss';
@import './components.css' prefix(ui);
`
async function loadStylesheet(id: string, base: string) {
return {
path: '',
base,
content:
id === 'tailwindcss'
? css`
@tailwind utilities;
`
: css`
@theme {
--color-red: red;
}
@utility card {
color: var(--color-red);
}
`,
}
}
expect(await run(['ui:underline'], input, { loadStylesheet })).toEqual('')
expect(await run(['underline', 'ui:card'], input, { loadStylesheet })).toMatchInlineSnapshot(`
"
.ui\\:card {
color: var(--color-red);
}
.underline {
text-decoration-line: underline;
}
:root, :host {
--color-red: red;
}
"
`)
})
test('custom import prefixes do not shadow variants with the same name', async () => {
let input = css`
@import 'tailwindcss';
@import './components.css' prefix(sm);
`
async function loadStylesheet(id: string, base: string) {
return {
path: '',
base,
content:
id === 'tailwindcss'
? css`
@theme {
--breakpoint-sm: 40rem;
}
@tailwind utilities;
`
: css`
@utility card {
color: red;
}
`,
}
}
expect(await run(['sm:card', 'sm:flex'], input, { loadStylesheet })).toMatchInlineSnapshot(`
"
.sm\\:card {
color: red;
}
@media (min-width: 40rem) {
.sm\\:flex {
display: flex;
}
}
"
`)
})
test('a prefix must be letters only', async () => {
let input = css`
@theme reference prefix(__);