refactor class name extraction and stringify

master
Brad Cornes 2020-04-13 01:44:43 +01:00
parent 9caa94fcb8
commit 3b50a445a3
7 changed files with 287 additions and 214 deletions

View File

@ -18,20 +18,6 @@ function getClassNamesFromSelector(selector) {
const { nodes: subSelectors } = selectorParser().astSync(selector) const { nodes: subSelectors } = selectorParser().astSync(selector)
for (let i = 0; i < subSelectors.length; i++) { for (let i = 0; i < subSelectors.length; i++) {
// const final = subSelectors[i].nodes[subSelectors[i].nodes.length - 1]
// if (final.type === 'class') {
// const scope = subSelectors[i].nodes.slice(
// 0,
// subSelectors[i].nodes.length - 1
// )
// classNames.push({
// className: String(final).trim(),
// scope: createSelectorFromNodes(scope)
// })
// }
let scope = [] let scope = []
for (let j = 0; j < subSelectors[i].nodes.length; j++) { for (let j = 0; j < subSelectors[i].nodes.length; j++) {
let node = subSelectors[i].nodes[j] let node = subSelectors[i].nodes[j]
@ -47,39 +33,28 @@ function getClassNamesFromSelector(selector) {
} }
classNames.push({ classNames.push({
className: String(node) className: node.value.trim(),
.trim()
.substr(1),
scope: createSelectorFromNodes(scope), scope: createSelectorFromNodes(scope),
__rule: j === subSelectors[i].nodes.length - 1, __rule: j === subSelectors[i].nodes.length - 1,
// __pseudo: createSelectorFromNodes(pseudo) __pseudo: pseudo.length === 0 ? null : pseudo.map(String),
__pseudo: pseudo.length === 0 ? null : pseudo.map(String)
}) })
} }
scope.push(node, ...pseudo) scope.push(node, ...pseudo)
} }
} }
// console.log(classNames)
return classNames return classNames
} }
// console.log(getClassNamesFromSelector('h1, h2, h3, .foo .bar, .baz'))
// const css = fs.readFileSync(path.resolve(__dirname, 'tailwind.css'), 'utf8')
async function process(ast) { async function process(ast) {
const start = new Date()
const tree = {} const tree = {}
const commonContext = {} const commonContext = {}
ast.root.walkRules(rule => { ast.root.walkRules((rule) => {
const classNames = getClassNamesFromSelector(rule.selector) const classNames = getClassNamesFromSelector(rule.selector)
const decls = { __decls: true } const decls = {}
rule.walkDecls(decl => { rule.walkDecls((decl) => {
decls[decl.prop] = decl.value decls[decl.prop] = decl.value
}) })
@ -96,49 +71,48 @@ async function process(ast) {
const context = keys.concat([]) const context = keys.concat([])
const baseKeys = classNames[i].className.split('__TAILWIND_SEPARATOR__') const baseKeys = classNames[i].className.split('__TAILWIND_SEPARATOR__')
const contextKeys = baseKeys.slice(0, baseKeys.length - 1) const contextKeys = baseKeys.slice(0, baseKeys.length - 1)
const index = []
if (classNames[i].scope) { const existing = dlv(tree, baseKeys)
let index = [] if (typeof existing !== 'undefined') {
const existing = dlv(tree, baseKeys) if (Array.isArray(existing)) {
if (typeof existing !== 'undefined') { const scopeIndex = existing.findIndex(
if (Array.isArray(existing)) { (x) =>
const scopeIndex = existing.findIndex( x.__scope === classNames[i].scope &&
x => x.__scope === classNames[i].scope arraysEqual(existing.__context, context)
) )
if (scopeIndex > -1) { if (scopeIndex > -1) {
keys.unshift(scopeIndex) keys.unshift(scopeIndex)
index.push(scopeIndex) index.push(scopeIndex)
} else {
keys.unshift(existing.length)
index.push(existing.length)
}
} else { } else {
if (existing.__scope !== classNames[i].scope) { keys.unshift(existing.length)
dset(tree, baseKeys, [existing]) index.push(existing.length)
keys.unshift(1) }
index.push(1) } else {
} if (
existing.__scope !== classNames[i].scope ||
!arraysEqual(existing.__context, context)
) {
dset(tree, baseKeys, [existing])
keys.unshift(1)
index.push(1)
} }
} }
if (classNames[i].__rule) {
dset(tree, [...baseKeys, ...index, '__rule'], true)
dsetEach(tree, [...baseKeys, ...keys], decls)
}
if (classNames[i].__pseudo) {
dset(tree, [...baseKeys, ...keys, '__pseudo'], classNames[i].__pseudo)
}
dset(tree, [...baseKeys, ...index, '__scope'], classNames[i].scope)
} else {
if (classNames[i].__rule) {
dset(tree, [...baseKeys, '__rule'], true)
dsetEach(tree, [...baseKeys, ...keys], decls)
} else {
dset(tree, [...baseKeys, ...keys], {})
}
if (classNames[i].__pseudo) {
dset(tree, [...baseKeys, ...keys, '__pseudo'], classNames[i].__pseudo)
}
} }
if (classNames[i].__rule) {
dset(tree, [...baseKeys, ...index, '__rule'], true)
dsetEach(tree, [...baseKeys, ...index], decls)
}
if (classNames[i].__pseudo) {
dset(tree, [...baseKeys, '__pseudo'], classNames[i].__pseudo)
}
dset(tree, [...baseKeys, ...index, '__scope'], classNames[i].scope)
dset(
tree,
[...baseKeys, ...index, '__context'],
context.concat([]).reverse()
)
// common context // common context
if (classNames[i].__pseudo) { if (classNames[i].__pseudo) {
@ -157,15 +131,12 @@ async function process(ast) {
} }
} }
}) })
// console.log(`${new Date() - start}ms`)
// console.log(tree)
// console.log(commonContext)
return { classNames: tree, context: commonContext } return { classNames: tree, context: commonContext }
} }
function intersection(arr1, arr2) { function intersection(arr1, arr2) {
return arr1.filter(value => arr2.indexOf(value) !== -1) return arr1.filter((value) => arr2.indexOf(value) !== -1)
} }
function dsetEach(obj, keys, values) { function dsetEach(obj, keys, values) {
@ -175,14 +146,15 @@ function dsetEach(obj, keys, values) {
} }
} }
export default process function arraysEqual(a, b) {
if (a === b) return true
if (a == null || b == null) return false
if (a.length !== b.length) return false
// process(` for (let i = 0; i < a.length; ++i) {
// .bg-red { if (a[i] !== b[i]) return false
// background-color: red; }
// } return true
// .bg-red { }
// color: white;
// }`).then(x => { export default process
// console.log(x)
// })

View File

@ -3,9 +3,73 @@ const esmImport = require('esm')(module)
const process = esmImport('../src/extractClassNames.mjs').default const process = esmImport('../src/extractClassNames.mjs').default
postcss = postcss([postcss.plugin('no-op', () => () => {})]) postcss = postcss([postcss.plugin('no-op', () => () => {})])
const processCss = async css => const processCss = async (css) =>
process(await postcss.process(css, { from: undefined })) process(await postcss.process(css, { from: undefined }))
test('processes default container plugin', async () => {
const result = await processCss(`
.container {
width: 100%
}
@media (min-width: 640px) {
.container {
max-width: 640px
}
}
@media (min-width: 768px) {
.container {
max-width: 768px
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px
}
}
@media (min-width: 1280px) {
.container {
max-width: 1280px
}
}
`)
expect(result).toEqual({
context: {},
classNames: {
container: [
{ __context: [], __rule: true, __scope: null, width: '100%' },
{
__rule: true,
__scope: null,
__context: ['@media (min-width: 640px)'],
'max-width': '640px',
},
{
__rule: true,
__scope: null,
__context: ['@media (min-width: 768px)'],
'max-width': '768px',
},
{
__rule: true,
__scope: null,
__context: ['@media (min-width: 1024px)'],
'max-width': '1024px',
},
{
__rule: true,
__scope: null,
__context: ['@media (min-width: 1280px)'],
'max-width': '1280px',
},
],
},
})
})
test('foo', async () => { test('foo', async () => {
const result = await processCss(` const result = await processCss(`
@media (min-width: 640px) { @media (min-width: 640px) {
@ -24,43 +88,42 @@ test('foo', async () => {
expect(result).toEqual({ expect(result).toEqual({
context: { context: {
sm: ['@media (min-width: 640px)'], sm: ['@media (min-width: 640px)'],
hover: [':hover'] hover: [':hover'],
}, },
classNames: { classNames: {
sm: { sm: {
'bg-red': { 'bg-red': {
__rule: true, __rule: true,
'@media (min-width: 640px)': { __scope: null,
__decls: true, __context: ['@media (min-width: 640px)'],
'background-color': 'red' 'background-color': 'red',
}
}, },
hover: { hover: {
'bg-red': { 'bg-red': {
__rule: true, __rule: true,
'@media (min-width: 640px)': { __scope: null,
__decls: true, __context: ['@media (min-width: 640px)'],
__pseudo: [':hover'], __pseudo: [':hover'],
'background-color': 'red' 'background-color': 'red',
} },
} },
}
}, },
hover: { hover: {
'bg-red': { 'bg-red': {
__rule: true, __rule: true,
__decls: true, __scope: null,
__pseudo: [':hover'], __pseudo: [':hover'],
'background-color': 'red' __context: [],
} 'background-color': 'red',
} },
} },
},
}) })
}) })
test('processes basic css', async () => { test.only('processes basic css', async () => {
const result = await processCss(` const result = await processCss(`
.bg-red { .bg-red\\:foo {
background-color: red; background-color: red;
} }
`) `)
@ -70,10 +133,11 @@ test('processes basic css', async () => {
classNames: { classNames: {
'bg-red': { 'bg-red': {
__rule: true, __rule: true,
__decls: true, __scope: null,
'background-color': 'red' __context: [],
} 'background-color': 'red',
} },
},
}) })
}) })
@ -89,11 +153,12 @@ test('processes pseudo selectors', async () => {
classNames: { classNames: {
'bg-red': { 'bg-red': {
__rule: true, __rule: true,
__decls: true, __scope: null,
__context: [],
__pseudo: [':first-child', '::after'], __pseudo: [':first-child', '::after'],
'background-color': 'red' 'background-color': 'red',
} },
} },
}) })
}) })
@ -108,15 +173,17 @@ test('processes pseudo selectors in scope', async () => {
context: {}, context: {},
classNames: { classNames: {
scope: { scope: {
__pseudo: [':hover'] __context: [],
__pseudo: [':hover'],
__scope: null,
}, },
'bg-red': { 'bg-red': {
__context: [],
__rule: true, __rule: true,
__decls: true,
__scope: '.scope:hover', __scope: '.scope:hover',
'background-color': 'red' 'background-color': 'red',
} },
} },
}) })
}) })
@ -133,15 +200,17 @@ test('processes multiple class names in the same rule', async () => {
classNames: { classNames: {
'bg-red': { 'bg-red': {
__rule: true, __rule: true,
__decls: true, __scope: null,
'background-color': 'red' __context: [],
'background-color': 'red',
}, },
'bg-red-again': { 'bg-red-again': {
__rule: true, __rule: true,
__decls: true, __scope: null,
'background-color': 'red' __context: [],
} 'background-color': 'red',
} },
},
}) })
}) })
@ -159,12 +228,35 @@ test('processes media queries', async () => {
classNames: { classNames: {
'bg-red': { 'bg-red': {
__rule: true, __rule: true,
'@media (min-width: 768px)': { __scope: null,
__decls: true, __context: ['@media (min-width: 768px)'],
'background-color': 'red' 'background-color': 'red',
},
},
})
})
test('processes nested at-rules', async () => {
const result = await processCss(`
@supports (display: grid) {
@media (min-width: 768px) {
.bg-red {
background-color: red;
} }
} }
} }
`)
expect(result).toEqual({
context: {},
classNames: {
'bg-red': {
__rule: true,
__scope: null,
__context: ['@supports (display: grid)', '@media (min-width: 768px)'],
'background-color': 'red',
},
},
}) })
}) })
@ -183,11 +275,12 @@ test('merges declarations', async () => {
classNames: { classNames: {
'bg-red': { 'bg-red': {
__rule: true, __rule: true,
__decls: true, __scope: null,
__context: [],
'background-color': 'red', 'background-color': 'red',
color: 'white' color: 'white',
} },
} },
}) })
}) })
@ -201,14 +294,17 @@ test('processes class name scope', async () => {
expect(result).toEqual({ expect(result).toEqual({
context: {}, context: {},
classNames: { classNames: {
scope: {}, scope: {
__context: [],
__scope: null,
},
'bg-red': { 'bg-red': {
__rule: true, __rule: true,
__decls: true, __context: [],
__scope: '.scope', __scope: '.scope',
'background-color': 'red' 'background-color': 'red',
} },
} },
}) })
}) })
@ -228,29 +324,29 @@ test('processes multiple scopes for the same class name', async () => {
expect(result).toEqual({ expect(result).toEqual({
context: {}, context: {},
classNames: { classNames: {
scope1: {}, scope1: { __context: [], __scope: null },
scope2: {}, scope2: { __context: [], __scope: null },
scope3: {}, scope3: { __context: [], __scope: null },
'bg-red': [ 'bg-red': [
{ {
__rule: true, __rule: true,
__decls: true, __context: [],
__scope: '.scope1', __scope: '.scope1',
'background-color': 'red' 'background-color': 'red',
}, },
{ {
__rule: true, __rule: true,
__decls: true, __context: [],
__scope: '.scope2 +', __scope: '.scope2 +',
'background-color': 'red' 'background-color': 'red',
}, },
{ {
__rule: true, __rule: true,
__decls: true, __context: [],
__scope: '.scope3 >', __scope: '.scope3 >',
'background-color': 'red' 'background-color': 'red',
} },
] ],
} },
}) })
}) })

View File

@ -27,6 +27,7 @@ function completionsFromClassList(
let sep = ':' let sep = ':'
let parts = partialClassName.split(sep) let parts = partialClassName.split(sep)
let subset: any let subset: any
let subsetKey: string[] = []
let isSubset: boolean = false let isSubset: boolean = false
let replacementRange = { let replacementRange = {
@ -42,6 +43,7 @@ function completionsFromClassList(
subset = dlv(state.classNames.classNames, keys) subset = dlv(state.classNames.classNames, keys)
if (typeof subset !== 'undefined' && typeof subset.__rule === 'undefined') { if (typeof subset !== 'undefined' && typeof subset.__rule === 'undefined') {
isSubset = true isSubset = true
subsetKey = keys
replacementRange = { replacementRange = {
...replacementRange, ...replacementRange,
start: { start: {
@ -62,7 +64,7 @@ function completionsFromClassList(
(className) => { (className) => {
let kind: CompletionItemKind = CompletionItemKind.Constant let kind: CompletionItemKind = CompletionItemKind.Constant
let documentation: string = null let documentation: string = null
if (isContextItem(state, [className])) { if (isContextItem(state, [...subsetKey, className])) {
kind = CompletionItemKind.Module kind = CompletionItemKind.Module
} else { } else {
const color = getColor(state, [className]) const color = getColor(state, [className])
@ -76,6 +78,7 @@ function completionsFromClassList(
label: className, label: className,
kind, kind,
documentation, documentation,
data: [...subsetKey, className],
textEdit: { textEdit: {
newText: className, newText: className,
range: replacementRange, range: replacementRange,
@ -514,20 +517,20 @@ export function resolveCompletionItem(
return item return item
} }
const className = state.classNames.classNames[item.label] const className = dlv(state.classNames.classNames, item.data)
if (isContextItem(state, [item.label])) { if (isContextItem(state, item.data)) {
item.detail = state.classNames.context[item.label].join(', ') item.detail = state.classNames.context[
item.data[item.data.length - 1]
].join(', ')
} else { } else {
item.detail = getCssDetail(state, className) item.detail = getCssDetail(state, className)
if (!item.documentation) { if (!item.documentation) {
item.documentation = stringifyCss(className) const css = stringifyCss(item.data.join(':'), className)
if (item.detail === item.documentation) { if (css) {
item.documentation = null item.documentation = {
} else { kind: MarkupKind.Markdown,
// item.documentation = { value: ['```css', css, '```'].join('\n'),
// kind: MarkupKind.Markdown, }
// value: ['```css', item.documentation, '```'].join('\n')
// }
} }
} }
} }
@ -537,7 +540,8 @@ export function resolveCompletionItem(
function isContextItem(state: State, keys: string[]): boolean { function isContextItem(state: State, keys: string[]): boolean {
const item = dlv(state.classNames.classNames, keys) const item = dlv(state.classNames.classNames, keys)
return Boolean( return Boolean(
!item.__rule && isObject(item) &&
!item.__rule &&
!Array.isArray(item) && !Array.isArray(item) &&
state.classNames.context[keys[keys.length - 1]] state.classNames.context[keys[keys.length - 1]]
) )
@ -555,13 +559,8 @@ function getCssDetail(state: State, className: any): string {
if (Array.isArray(className)) { if (Array.isArray(className)) {
return `${className.length} rules` return `${className.length} rules`
} }
let withoutMeta = removeMeta(className) if (className.__rule === true) {
if (className.__decls === true) { return stringifyDecls(removeMeta(className))
return stringifyDecls(withoutMeta)
} }
let keys = Object.keys(withoutMeta) return null
if (keys.length === 1) {
return getCssDetail(state, className[keys[0]])
}
return `${keys.length} rules`
} }

View File

@ -6,7 +6,6 @@ import {
} from '../util/getClassNameAtPosition' } from '../util/getClassNameAtPosition'
import { stringifyCss, stringifyConfigValue } from '../util/stringify' import { stringifyCss, stringifyConfigValue } from '../util/stringify'
const dlv = require('dlv') const dlv = require('dlv')
import escapeClassName from 'css.escape'
import { isHtmlContext } from '../util/html' import { isHtmlContext } from '../util/html'
import { isCssContext } from '../util/css' import { isCssContext } from '../util/css'
@ -90,21 +89,11 @@ function provideClassNameHover(
return { return {
contents: { contents: {
language: 'css', language: 'css',
value: stringifyCss(dlv(state.classNames.classNames, parts), { value: stringifyCss(
selector: augmentClassName(parts, state), hovered.className,
}), dlv(state.classNames.classNames, parts)
),
}, },
range: hovered.range, range: hovered.range,
} }
} }
// TODO
function augmentClassName(className: string | string[], state: State): string {
const parts = Array.isArray(className)
? className
: getClassNameParts(state, className)
const obj = dlv(state.classNames.classNames, parts)
const pseudo = obj.__pseudo ? obj.__pseudo.join('') : ''
const scope = obj.__scope ? `${obj.__scope} ` : ''
return `${scope}.${escapeClassName(parts.join(state.separator))}${pseudo}`
}

View File

@ -16,7 +16,7 @@ const COLOR_PROPS = [
'outline-color', 'outline-color',
'stop-color', 'stop-color',
'stroke', 'stroke',
'text-decoration-color' 'text-decoration-color',
] ]
const COLOR_NAMES = { const COLOR_NAMES = {
@ -169,12 +169,12 @@ const COLOR_NAMES = {
white: '#fff', white: '#fff',
whitesmoke: '#f5f5f5', whitesmoke: '#f5f5f5',
yellow: '#ff0', yellow: '#ff0',
yellowgreen: '#9acd32' yellowgreen: '#9acd32',
} }
export function getColor(state: State, keys: string[]): string { export function getColor(state: State, keys: string[]): string {
const item = dlv(state.classNames.classNames, keys) const item = dlv(state.classNames.classNames, keys)
if (!item.__decls) return null if (!item.__rule) return null
const props = Object.keys(removeMeta(item)) const props = Object.keys(removeMeta(item))
if (props.length === 0 || props.length > 1) return null if (props.length === 0 || props.length > 1) return null
const prop = props[0] const prop = props[0]

View File

@ -49,7 +49,8 @@ export function getClassNameParts(state: State, className: string): string[] {
let parts: string[] = className.split(separator) let parts: string[] = className.split(separator)
if (parts.length === 1) { if (parts.length === 1) {
return dlv(state.classNames.classNames, [className, '__rule']) === true return dlv(state.classNames.classNames, [className, '__rule']) === true ||
Array.isArray(dlv(state.classNames.classNames, [className]))
? [className] ? [className]
: null : null
} }
@ -73,7 +74,10 @@ export function getClassNameParts(state: State, className: string): string[] {
] ]
return possibilities.find((key) => { return possibilities.find((key) => {
if (dlv(state.classNames.classNames, [...key, '__rule']) === true) { if (
dlv(state.classNames.classNames, [...key, '__rule']) === true ||
Array.isArray(dlv(state.classNames.classNames, [...key]))
) {
return true return true
} }
return false return false

View File

@ -1,4 +1,6 @@
import removeMeta from './removeMeta' import removeMeta from './removeMeta'
const dlv = require('dlv')
import escapeClassName from 'css.escape'
export function stringifyConfigValue(x: any): string { export function stringifyConfigValue(x: any): string {
if (typeof x === 'string') return x if (typeof x === 'string') return x
@ -12,34 +14,45 @@ export function stringifyConfigValue(x: any): string {
return null return null
} }
export function stringifyCss( export function stringifyCss(className: string, obj: any): string {
obj: any, if (obj.__rule !== true && !Array.isArray(obj)) return null
{ indent = 0, selector }: { indent?: number; selector?: string } = {}
): string { if (Array.isArray(obj)) {
let indentStr = '\t'.repeat(indent) const rules = obj.map((x) => stringifyCss(className, x)).filter(Boolean)
if (obj.__decls === true) { if (rules.length === 0) return null
let before = '' return rules.join('\n\n')
let after = ''
if (selector) {
before = `${indentStr}${selector} {\n`
after = `\n${indentStr}}`
indentStr += '\t'
}
return (
before +
Object.keys(removeMeta(obj)).reduce((acc, curr, i) => {
return `${acc}${i === 0 ? '' : '\n'}${indentStr}${curr}: ${obj[curr]};`
}, '') +
after
)
} }
return Object.keys(removeMeta(obj)).reduce((acc, curr, i) => {
return `${acc}${i === 0 ? '' : '\n'}${indentStr}${curr} {\n${stringifyCss( let css = ``
obj[curr],
{ const context = dlv(obj, '__context', [])
indent: indent + 1, const props = Object.keys(removeMeta(obj))
selector, if (props.length === 0) return null
}
)}\n${indentStr}}` for (let i = 0; i < context.length; i++) {
css += `${'\t'.repeat(i)}${context[i]} {\n`
}
const indentStr = '\t'.repeat(context.length)
const decls = props.reduce((acc, curr, i) => {
return `${acc}${i === 0 ? '' : '\n'}${indentStr + '\t'}${curr}: ${
obj[curr]
};`
}, '') }, '')
css += `${indentStr}${augmentClassName(
className,
obj
)} {\n${decls}\n${indentStr}}`
for (let i = context.length - 1; i >= 0; i--) {
css += `${'\t'.repeat(i)}\n}`
}
return css
}
function augmentClassName(className: string, obj: any): string {
const pseudo = obj.__pseudo ? obj.__pseudo.join('') : ''
const scope = obj.__scope ? `${obj.__scope} ` : ''
return `${scope}.${escapeClassName(className)}${pseudo}`
} }