From 185f152f60df2d0bc0bfa31898acd3fe94f49453 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 25 Oct 2024 18:37:07 +0800 Subject: [PATCH] feat: parse & transform --- package.json | 1 + pnpm-lock.yaml | 19 ++--- src/index.ts | 54 +------------- src/maps/border.ts | 12 ++- src/maps/index.ts | 9 +++ src/maps/margin.ts | 10 +++ src/maps/position.ts | 9 ++- src/parser/declaration.ts | 71 ++++++++++++++++++ src/transfromer/index.ts | 93 +++++++++++++++++++++++ src/types.ts | 51 +++++++++++-- src/utils/index.ts | 3 + test/index.test.ts | 151 ++++++++++++++++++++++++++++++++++++-- 12 files changed, 401 insertions(+), 82 deletions(-) create mode 100644 src/maps/index.ts create mode 100644 src/maps/margin.ts create mode 100644 src/parser/declaration.ts create mode 100644 src/transfromer/index.ts create mode 100644 src/utils/index.ts diff --git a/package.json b/package.json index 33f35e3..ea5a53f 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@types/css-tree": "^2.3.8", + "@unocss/core": "^0.63.6", "css-tree": "^3.0.0", "magic-string": "^0.30.12" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e36af36..8b76cb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@types/css-tree': specifier: ^2.3.8 version: 2.3.8 + '@unocss/core': + specifier: ^0.63.6 + version: 0.63.6 css-tree: specifier: ^3.0.0 version: 3.0.0 @@ -968,6 +971,9 @@ packages: resolution: {integrity: sha512-zTQD6WLNTre1hj5wp09nBIDiOc2U5r/qmzo7wxPn4ZgAjHql09EofqhF9WF+fZHzL5aCyaIpPcT2hyxl73kr9A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@unocss/core@0.63.6': + resolution: {integrity: sha512-Q4QPgJ271Up89+vIqqOKgtdCKkFpHqvHN8W1LUlKPqtYnOvVYaOIVNAZowaIdEhPuc83yLc6Tg2+7riK18QKEw==} + '@vitest/eslint-plugin@1.1.0': resolution: {integrity: sha512-Ur80Y27Wbw8gFHJ3cv6vypcjXmrx6QHfw+q435h6Q2L+tf+h4Xf5pJTCL4YU/Jps9EVeggQxS85OcUZU7sdXRw==} peerDependencies: @@ -1986,9 +1992,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - magic-string@0.30.10: - resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==} - magic-string@0.30.12: resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} @@ -3664,6 +3667,8 @@ snapshots: '@typescript-eslint/types': 8.4.0 eslint-visitor-keys: 3.4.3 + '@unocss/core@0.63.6': {} + '@vitest/eslint-plugin@1.1.0(@typescript-eslint/utils@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)(vitest@2.0.5(@types/node@22.5.4))': dependencies: eslint: 9.9.1(jiti@1.21.6) @@ -3726,7 +3731,7 @@ snapshots: '@vue/reactivity-transform': 3.3.10 '@vue/shared': 3.3.10 estree-walker: 2.0.2 - magic-string: 0.30.10 + magic-string: 0.30.12 postcss: 8.4.44 source-map-js: 1.2.0 @@ -3741,7 +3746,7 @@ snapshots: '@vue/compiler-core': 3.3.10 '@vue/shared': 3.3.10 estree-walker: 2.0.2 - magic-string: 0.30.10 + magic-string: 0.30.12 '@vue/shared@3.3.10': {} @@ -4853,10 +4858,6 @@ snapshots: dependencies: yallist: 3.1.1 - magic-string@0.30.10: - dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 - magic-string@0.30.12: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 diff --git a/src/index.ts b/src/index.ts index 315bd0e..fd375a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,8 @@ import { parse } from 'css-tree' import MagicString from 'magic-string' -import type { Declaration, Rule, StyleSheet } from 'css-tree' +import type { Rule, StyleSheet } from 'css-tree' -export function toArray(value: T | T[] = []): T[] { - return Array.isArray(value) ? value : [value] -} +export * from './parser/declaration' function blockSource(source: string) { if (source.includes('{') && source.includes('}')) { @@ -28,51 +26,3 @@ function handleRuleNode(source: MagicString, node: Rule) { // console.dir(node) // const selector = node.prelude.children } - -interface CssValueParsedMeta { - value: number | string - unit?: string -} - -interface CssValueParsed { - prop: string - meta: CssValueParsedMeta[] -} - -export function handleDeclarationNode(node: Declaration): CssValueParsed | undefined { - if (node.type !== 'Declaration') { - return - } - - const prop = node.property - let meta: CssValueParsedMeta[] - if (node.value.type === 'Raw') { - meta = toArray({ value: node.value.value }) - } - else { - meta = node.value.children.map((child) => { - switch (child.type) { - case 'Dimension': - return { - value: child.value, - unit: child.unit, - } - - case 'Hash': - return { - value: child.value, - } - - case 'Identifier': - return { - value: child.name, - } - } - }) as unknown as CssValueParsedMeta[] - } - - return { - prop, - meta, - } -} diff --git a/src/maps/border.ts b/src/maps/border.ts index 9cf0477..72d08b5 100644 --- a/src/maps/border.ts +++ b/src/maps/border.ts @@ -1,6 +1,10 @@ -import { positions, positionShort } from './position' +import { position, positionShort } from './position' +import type { PropsAtomicMap } from '../types' -export const border = [ - ['border', 'border'], - ['b', 'b'], +export const border: PropsAtomicMap[] = [ + ['border', ['border', 'b']], + [/^border-(top|bottom|left|right)(?:-\w+)?$/, ([, p]) => { + const index = position.indexOf(p) + return [`border-${positionShort[index]}`, `b-${positionShort[index]}`] + }], ] diff --git a/src/maps/index.ts b/src/maps/index.ts new file mode 100644 index 0000000..7a30837 --- /dev/null +++ b/src/maps/index.ts @@ -0,0 +1,9 @@ +import { border } from './border' +import { margin } from './margin' +import { positions } from './position' + +export const maps = [ + border, + positions, + margin, +].flat(1) diff --git a/src/maps/margin.ts b/src/maps/margin.ts new file mode 100644 index 0000000..6df435e --- /dev/null +++ b/src/maps/margin.ts @@ -0,0 +1,10 @@ +import { position, positionShort } from './position' +import type { PropsAtomicMap } from '../types' + +export const margin: PropsAtomicMap[] = [ + ['margin', ['margin', 'm']], + [/^margin-(top|bottom|left|right)(?:-\w+)?$/, ([, p]) => { + const index = position.indexOf(p) + return [`margin-${positionShort[index]}`, `m-${positionShort[index]}`] + }], +] diff --git a/src/maps/position.ts b/src/maps/position.ts index be236a3..833f9d4 100644 --- a/src/maps/position.ts +++ b/src/maps/position.ts @@ -1,2 +1,9 @@ -export const positions = ['top', 'bottom', 'left', 'right'] +import type { PropsAtomicMap } from '../types' + +export const position = ['top', 'bottom', 'left', 'right'] export const positionShort = ['t', 'b', 'l', 'r'] + +export const positions: PropsAtomicMap[] = position.map((p, i) => [ + p, + [p, positionShort[i]], +]) diff --git a/src/parser/declaration.ts b/src/parser/declaration.ts new file mode 100644 index 0000000..8d295f9 --- /dev/null +++ b/src/parser/declaration.ts @@ -0,0 +1,71 @@ +import type { CssNode, Declaration, Dimension, List, Url } from 'css-tree' +import { toArray } from '../utils' +import type { CssValueParsed, CssValueParsedMeta } from '../types' + +export function parseDeclarationNode(node: Declaration): CssValueParsed | undefined { + if (node.type !== 'Declaration') { + return + } + + const prop = node.property + let meta: CssValueParsedMeta[] + + if (node.value.type === 'Raw') { + meta = toArray({ value: node.value.value }) + } + else { + meta = node.value.children.map(child => parseChildNode(child)) as unknown as CssValueParsedMeta[] + } + + return { + prop, + meta, + } +} + +function parseChildNode(child: CssNode): CssValueParsedMeta | undefined { + switch (child.type) { + case 'Dimension': + case 'Number': + case 'String': + case 'Percentage': + case 'Hash': + case 'Url': + case 'Raw': + case 'Operator': { + let _v: any = child.value + if (child.type === 'Hash') { + _v = `#${child.value}` + } + else if (child.type === 'Percentage') { + _v = `${child.value}%` + } + + const meta: CssValueParsedMeta = { value: _v, type: child.type } + + if ((child as Dimension).unit) + meta.unit = (child as Dimension).unit + + if ((child as Url).type === 'Url') + meta.fname = 'url' + + return meta + } + + case 'Identifier': + return { + value: child.name, + type: child.type, + } + + case 'Function': + return { + value: child.children.map(child => parseChildNode(child)!).toArray(), + fname: child.name, + type: child.type, + } + + default: + return undefined + } +} diff --git a/src/transfromer/index.ts b/src/transfromer/index.ts new file mode 100644 index 0000000..03fc707 --- /dev/null +++ b/src/transfromer/index.ts @@ -0,0 +1,93 @@ +import { maps } from '../maps' +import { toArray } from '../utils' +import type { AtomicComposed, CssValueParsed, CssValueParsedMeta, DynamicPropAtomicMap, StaticPropAtomicMap, TransfromOptions } from '../types' + +const atomicCache: Record = {} + +const nonTransfromPxProps = [ + 'border', +] + +/** + * 将 CssValueParsedMeta[] 转换为 atomic css + * @param meta CssValueParsedMeta[] + * @returns + */ +export function transfrom(metas: CssValueParsedMeta[], options: TransfromOptions = {}): string[] { + const atomics: string[] = [] +} + +export function transfromParsed(parsed: CssValueParsed, options: TransfromOptions = {}) { + let key: string | undefined + + const { + shortify = false, + } = options + const { prop, meta } = parsed + + for (const map of maps) { + let atomics: AtomicComposed | undefined + + if (typeof map[0] === 'string') { + if (prop === map[0]) { + atomics = toArray((map as StaticPropAtomicMap)[1]) as AtomicComposed + } + } + else { + const match = prop.match(map[0]) + if (match) { + const matched = (map as DynamicPropAtomicMap)[1](match) + if (matched) { + atomics = toArray(matched) as AtomicComposed + } + } + } + + if (atomics) { + if (shortify) { + key = atomics[1] || atomics[0] + } + else { + key = atomics[0] + } + break + } + } + + if (!key) { + return + } + + return analyzeMeta({ + prop, + meta, + key, + }) +} + +function analyzeMeta(bundle: { + meta: CssValueParsed['meta'] + key: string + prop: string +}): string[] { + const { meta, key, prop } = bundle + const atomics: string[] = [] + + for (const m of meta) { + if (Array.isArray(m)) { + + } + else { + if (m.unit === 'px' && /^\d+$/.test(m.value as string)) { + if (nonTransfromPxProps.some(p => prop.includes(p))) { + atomics.push(`${key}-[${m.value}${m.unit}]`) + } + else { + atomics.push(`${key}-${Number(m.value) / 4}`) + } + } + } + } + + return atomics +} diff --git a/src/types.ts b/src/types.ts index ae019b8..38c3092 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,43 @@ -/** - * [match property, uno rule starter] - * - * @example - * ['color', 'c'] - * ['background-color', 'bg'] - */ -export type KeyToUno = [string, string] +import type { CssNode } from 'css-tree' + +export type Atomic = string +export type AtomicShort = string +export type AtomicComposed = [Atomic, AtomicShort?] +export type StaticPropAtomicMap = [string, Atomic | AtomicComposed] +export type DynamicPropAtomicMap = [RegExp, (match: RegExpMatchArray) => Atomic | [Atomic, AtomicShort] | undefined] +export type PropsAtomicMap = StaticPropAtomicMap | DynamicPropAtomicMap + +// Parser types + +export interface CssValueParsedMeta { + /** + * Value of the property or function + */ + value: string | CssValueParsedMeta[] + /** + * Unit of the value + */ + unit?: string + /** + * Function name + */ + fname?: string + /** + * Type of the value + */ + type: CssNode['type'] +} + +export interface CssValueParsed { + prop: string + meta: CssValueParsedMeta[] +} + +export interface TransfromOptions { + /** + * 是否开启简写模式 + * + * @default false + */ + shortify?: boolean +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..14f54f5 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +export function toArray(value: T | T[] = []): T[] { + return Array.isArray(value) ? value : [value] +} diff --git a/test/index.test.ts b/test/index.test.ts index 36809c7..14db2c4 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,7 +1,8 @@ import { parse } from 'css-tree' import { describe, expect, it } from 'vitest' import type { Declaration } from 'css-tree' -import { handleDeclarationNode, parseCSS } from '../src' +import { parseCSS, parseDeclarationNode } from '../src' +import { transfromParsed } from '../src/transfromer' const source = `/** * Paste or drop some CSS here and explore @@ -26,31 +27,165 @@ ul li { ` describe('should', () => { - it('exported', () => { - // const css = 'color: red' - const css = 'border: 1px solid #eee' - const ast = parse(css, { + function generateParsed(code: string) { + const ast = parse(code, { context: 'declaration', positions: true, }) as Declaration - const result = handleDeclarationNode(ast) + return parseDeclarationNode(ast) + } - expect(result).toMatchInlineSnapshot(` + it('declarations', () => { + expect(generateParsed('content: ""')).toMatchInlineSnapshot(` { "meta": [ { + "type": "String", + "value": "", + }, + ], + "prop": "content", + } + `) + + expect(generateParsed('color: red')).toMatchInlineSnapshot(` + { + "meta": [ + { + "type": "Identifier", + "value": "red", + }, + ], + "prop": "color", + } + `) + + expect(generateParsed('border: 1px solid #eee')).toMatchInlineSnapshot(` + { + "meta": [ + { + "type": "Dimension", "unit": "px", "value": "1", }, { + "type": "Identifier", "value": "solid", }, { - "value": "eee", + "type": "Hash", + "value": "#eee", }, ], "prop": "border", } `) + + expect(generateParsed('background-color: hsl(100% var(--foo) 100 / 1)')).toMatchInlineSnapshot(` + { + "meta": [ + { + "fname": "hsl", + "type": "Function", + "value": [ + { + "type": "Percentage", + "value": "100%", + }, + { + "fname": "var", + "type": "Function", + "value": [ + { + "type": "Identifier", + "value": "--foo", + }, + ], + }, + { + "type": "Number", + "value": "100", + }, + { + "type": "Operator", + "value": "/", + }, + { + "type": "Number", + "value": "1", + }, + ], + }, + ], + "prop": "background-color", + } + `) + + expect(generateParsed('margin-top: calc(10px + calc(var(--bar, 1)))')).toMatchInlineSnapshot(` + { + "meta": [ + { + "fname": "calc", + "type": "Function", + "value": [ + { + "type": "Dimension", + "unit": "px", + "value": "10", + }, + { + "type": "Operator", + "value": " + ", + }, + { + "fname": "calc", + "type": "Function", + "value": [ + { + "fname": "var", + "type": "Function", + "value": [ + { + "type": "Identifier", + "value": "--bar", + }, + { + "type": "Operator", + "value": ",", + }, + { + "type": "Raw", + "value": " 1", + }, + ], + }, + ], + }, + ], + }, + ], + "prop": "margin-top", + } + `) + }) + + it('transfromParsed', () => { + expect(transfromParsed( + generateParsed('border-top: 1px solid #eee')!, + { shortify: true }, + )).toMatchInlineSnapshot(` + [ + "b-t-[1px]", + ] + `) + + expect(transfromParsed( + generateParsed('margin: 12px')!, + { shortify: true }, + )).toMatchInlineSnapshot(` + [ + "m-3", + ] + `) }) })