diff --git a/packages/preset-mini/src/variants/breakpoints.ts b/packages/preset-mini/src/variants/breakpoints.ts index e865e0592b..a2e208fb87 100644 --- a/packages/preset-mini/src/variants/breakpoints.ts +++ b/packages/preset-mini/src/variants/breakpoints.ts @@ -4,7 +4,7 @@ import type { Theme } from '../theme' const regexCache: Record = {} -const calcMaxWidthBySize = (size: string) => { +export const calcMaxWidthBySize = (size: string) => { const value = size.match(/^-?[0-9]+\.?[0-9]*/)?.[0] || '' const unit = size.slice(value.length) const maxWidth = (parseFloat(value) - 0.1) diff --git a/packages/transformer-directives/src/index.ts b/packages/transformer-directives/src/index.ts index 3431ed1a83..939343acee 100644 --- a/packages/transformer-directives/src/index.ts +++ b/packages/transformer-directives/src/index.ts @@ -1,8 +1,10 @@ import { cssIdRE, expandVariantGroup, notNull, regexScopePlaceholder } from '@unocss/core' import type { SourceCodeTransformer, StringifiedUtil, UnoGenerator } from '@unocss/core' -import type { CssNode, Declaration, List, ListItem, Rule, Selector, SelectorList } from 'css-tree' +import type { Atrule, CssNode, Declaration, List, ListItem, Rule, Selector, SelectorList } from 'css-tree' import { clone, generate, parse, walk } from 'css-tree' import type MagicString from 'magic-string' +import { calcMaxWidthBySize } from '@unocss/preset-mini/variants' +import type { Theme } from '@unocss/preset-mini' type Writeable = { -readonly [P in keyof T]: T[P] } @@ -37,6 +39,7 @@ export default function transformerDirectives(options: TransformerDirectivesOpti } const themeFnRE = /theme\((.*?)\)/g +const screenRuleRE = /(@screen) (.+) /g export async function transformDirectives( code: MagicString, @@ -52,9 +55,10 @@ export async function transformDirectives( } = options const isApply = code.original.includes('@apply') || (varStyle !== false && code.original.includes(varStyle)) + const isScreen = code.original.includes('@screen') const hasThemeFn = code.original.match(themeFnRE) - if (!isApply && !hasThemeFn) + if (!isApply && !hasThemeFn && !isScreen) return const ast = parse(originalCode || code.original, { @@ -187,9 +191,69 @@ export async function transformDirectives( } } + const handleScreen = (node: Atrule) => { + let breakpointName = ''; let prefix + if (node.name === 'screen' && node.prelude?.type === 'Raw') + breakpointName = node.prelude.value.trim() + + if (!breakpointName) + return + + const match = breakpointName.match(/^(?:(lt|at)-)?(\w+)$/) + if (match) { + prefix = match[1] + breakpointName = match[2] + } + + const resolveBreakpoints = () => { + let breakpoints: Record | undefined + if (uno.userConfig && uno.userConfig.theme) + breakpoints = (uno.userConfig.theme as Theme).breakpoints + + if (!breakpoints) + breakpoints = (uno.config.theme as Theme).breakpoints + + return breakpoints + } + const variantEntries: Array<[string, string, number]> = Object.entries(resolveBreakpoints() ?? {}).map(([point, size], idx) => [point, size, idx]) + const generateMediaQuery = (breakpointName: string, prefix?: string) => { + const [, size, idx] = variantEntries.find(i => i[0] === breakpointName)! + if (prefix) { + if (prefix === 'lt') + return `@media (max-width: ${calcMaxWidthBySize(size)})` + else if (prefix === 'at') + return `@media (min-width: ${size})${variantEntries[idx + 1] ? ` and (max-width: ${calcMaxWidthBySize(variantEntries[idx + 1][1])})` : ''}` + + else throw new Error(`breakpoint variant not surpported: ${prefix}`) + } + return `@media (min-width: ${size})` + } + + if (!variantEntries.find(i => i[0] === breakpointName)) + throw new Error(`breakpoint ${breakpointName} not found`) + + const offset = node.loc!.start.offset + const str = code.original.slice(offset, node.loc!.end.offset) + const matches = Array.from(str.matchAll(screenRuleRE)) + + if (!matches.length) + return + + for (const match of matches) { + code.overwrite( + offset + match.index!, + offset + match.index! + match[0].length, + `${generateMediaQuery(breakpointName, prefix)} `, + ) + } + } + const stack: Promise[] = [] const processNode = async (node: CssNode, _item: ListItem, _list: List) => { + if (isScreen && node.type === 'Atrule') + handleScreen(node) + if (hasThemeFn && node.type === 'Declaration') handleThemeFn(node) diff --git a/test/transformer-directives.test.ts b/test/transformer-directives.test.ts index 9eba468348..9eb15f6a67 100644 --- a/test/transformer-directives.test.ts +++ b/test/transformer-directives.test.ts @@ -18,6 +18,16 @@ describe('transformer-directives', () => { shortcuts: { btn: 'px-2 py-3 md:px-4 bg-blue-500 text-white rounded', }, + theme: { + breakpoints: { + xs: '320px', + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + xxl: '1536px', + }, + }, }) async function transform(code: string, _uno: UnoGenerator = uno) { @@ -296,26 +306,7 @@ describe('transformer-directives', () => { }) test('custom breakpoints', async () => { - const customUno = createGenerator({ - presets: [ - presetUno(), - ], - theme: { - breakpoints: { - 'xs': '320px', - 'sm': '640px', - 'md': '768px', - 'lg': '1024px', - 'xl': '1280px', - '2xl': '1536px', - 'xxl': '1536px', - }, - }, - }) - const result = await transform( - '.grid { @apply grid grid-cols-2 xs:grid-cols-1 xxl:grid-cols-15 xl:grid-cols-10 sm:grid-cols-7 md:grid-cols-3 lg:grid-cols-4 }', - customUno, - ) + const result = await transform('.grid { @apply grid grid-cols-2 xs:grid-cols-1 xxl:grid-cols-15 xl:grid-cols-10 sm:grid-cols-7 md:grid-cols-3 lg:grid-cols-4 }') expect(result) .toMatchInlineSnapshot(` ".grid { @@ -396,10 +387,176 @@ describe('transformer-directives', () => { `) }) + test('@screen basic', async () => { + const result = await transform(` + .grid { + @apply grid grid-cols-2; + } + @screen xs { + .grid { + @apply grid-cols-1; + } + } + @screen sm { + .grid { + @apply grid-cols-3; + } + } + @screen md { + .grid { + @apply grid-cols-4; + } + } + @screen lg { + .grid { + @apply grid-cols-5; + } + } + @screen xl { + .grid { + @apply grid-cols-6; + } + } + @screen xxl { + .grid { + @apply grid-cols-7; + } + } + `) + expect(result) + .toMatchInlineSnapshot(` + ".grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + @media (min-width: 320px) { + .grid { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + } + @media (min-width: 640px) { + .grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + @media (min-width: 768px) { + .grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + @media (min-width: 1024px) { + .grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + } + @media (min-width: 1280px) { + .grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + } + @media (min-width: 1536px) { + .grid { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + } + " + `) + }) + + test('@screen lt variant', async () => { + const result = await transform(` + .grid { + @apply grid grid-cols-2; + } + @screen lt-xs { + .grid { + @apply grid-cols-1; + } + } + @screen lt-sm { + .grid { + @apply grid-cols-3; + } + } + @screen lt-md { + .grid { + @apply grid-cols-4; + } + } + `) + expect(result).toMatchInlineSnapshot(` + ".grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + @media (max-width: 319.9px) { + .grid { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + } + @media (max-width: 639.9px) { + .grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + @media (max-width: 767.9px) { + .grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + " + `) + }) + + test('@screen at variant', async () => { + const result = await transform(` + .grid { + @apply grid grid-cols-2; + } + @screen at-xs { + .grid { + @apply grid-cols-1; + } + } + @screen at-xl { + .grid { + @apply grid-cols-3; + } + } + @screen at-xxl { + .grid { + @apply grid-cols-4; + } + } + `) + expect(result).toMatchInlineSnapshot(` + ".grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + @media (min-width: 320px) and (max-width: 639.9px) { + .grid { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + } + @media (min-width: 1280px) and (max-width: 1535.9px) { + .grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + @media (min-width: 1536px) { + .grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + " + `) + }) + describe('theme()', () => { test('basic', async () => { const result = await transform( - `.btn { + `.btn { background-color: theme("colors.blue.500"); padding: theme("spacing.xs") theme("spacing.sm"); } @@ -422,14 +579,14 @@ describe('transformer-directives', () => { test('non-exist', async () => { expect(async () => await transform( - `.btn { + `.btn { color: theme("color.none.500"); }`, )).rejects .toMatchInlineSnapshot('[Error: theme of "color.none.500" did not found]') expect(async () => await transform( - `.btn { + `.btn { font-size: theme("size.lg"); }`, )).rejects @@ -438,7 +595,7 @@ describe('transformer-directives', () => { test('args', async () => { expect(async () => await transform( - `.btn { + `.btn { color: theme(); }`, )).rejects