Skip to content

Commit 1d7fd6e

Browse files
committed
Add CSS AST implementation
This is intended to mirror the AST implementation of Tailwind CSS v4.1.18+
1 parent 1fffe50 commit 1d7fd6e

File tree

14 files changed

+1686
-35
lines changed

14 files changed

+1686
-35
lines changed
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { parseAtRule } from './parse'
2+
import type { SourceLocation } from './source'
3+
import type { VisitContext } from '../util/walk'
4+
5+
const AT_SIGN = 0x40
6+
7+
export type StyleRule = {
8+
kind: 'rule'
9+
selector: string
10+
nodes: AstNode[]
11+
12+
src?: SourceLocation
13+
dst?: SourceLocation
14+
}
15+
16+
export type AtRule = {
17+
kind: 'at-rule'
18+
name: string
19+
params: string
20+
nodes: AstNode[]
21+
22+
src?: SourceLocation
23+
dst?: SourceLocation
24+
}
25+
26+
export type Declaration = {
27+
kind: 'declaration'
28+
property: string
29+
value: string | undefined
30+
important: boolean
31+
32+
src?: SourceLocation
33+
dst?: SourceLocation
34+
}
35+
36+
export type Comment = {
37+
kind: 'comment'
38+
value: string
39+
40+
src?: SourceLocation
41+
dst?: SourceLocation
42+
}
43+
44+
export type Context = {
45+
kind: 'context'
46+
context: Record<string, string | boolean>
47+
nodes: AstNode[]
48+
49+
src?: undefined
50+
dst?: undefined
51+
}
52+
53+
export type AtRoot = {
54+
kind: 'at-root'
55+
nodes: AstNode[]
56+
57+
src?: undefined
58+
dst?: undefined
59+
}
60+
61+
export type Rule = StyleRule | AtRule
62+
export type AstNode = StyleRule | AtRule | Declaration | Comment | Context | AtRoot
63+
export type Stylesheet = AstNode[]
64+
65+
export function styleRule(selector: string, nodes: AstNode[] = []): StyleRule {
66+
return {
67+
kind: 'rule',
68+
selector,
69+
nodes,
70+
}
71+
}
72+
73+
export function atRule(name: string, params: string = '', nodes: AstNode[] = []): AtRule {
74+
return {
75+
kind: 'at-rule',
76+
name,
77+
params,
78+
nodes,
79+
}
80+
}
81+
82+
export function rule(selector: string, nodes: AstNode[] = []): StyleRule | AtRule {
83+
if (selector.charCodeAt(0) === AT_SIGN) {
84+
return parseAtRule(selector, nodes)
85+
}
86+
87+
return styleRule(selector, nodes)
88+
}
89+
90+
export function decl(property: string, value: string | undefined, important = false): Declaration {
91+
return {
92+
kind: 'declaration',
93+
property,
94+
value,
95+
important,
96+
}
97+
}
98+
99+
export function comment(value: string): Comment {
100+
return {
101+
kind: 'comment',
102+
value: value,
103+
}
104+
}
105+
106+
export function context(context: Record<string, string | boolean>, nodes: AstNode[]): Context {
107+
return {
108+
kind: 'context',
109+
context,
110+
nodes,
111+
}
112+
}
113+
114+
export function atRoot(nodes: AstNode[]): AtRoot {
115+
return {
116+
kind: 'at-root',
117+
nodes,
118+
}
119+
}
120+
121+
export function cloneAstNode<T extends AstNode>(node: T): T {
122+
switch (node.kind) {
123+
case 'rule':
124+
return {
125+
kind: node.kind,
126+
selector: node.selector,
127+
nodes: node.nodes.map(cloneAstNode),
128+
src: node.src,
129+
dst: node.dst,
130+
} satisfies StyleRule as T
131+
132+
case 'at-rule':
133+
return {
134+
kind: node.kind,
135+
name: node.name,
136+
params: node.params,
137+
nodes: node.nodes.map(cloneAstNode),
138+
src: node.src,
139+
dst: node.dst,
140+
} satisfies AtRule as T
141+
142+
case 'at-root':
143+
return {
144+
kind: node.kind,
145+
nodes: node.nodes.map(cloneAstNode),
146+
src: node.src,
147+
dst: node.dst,
148+
} satisfies AtRoot as T
149+
150+
case 'context':
151+
return {
152+
kind: node.kind,
153+
context: { ...node.context },
154+
nodes: node.nodes.map(cloneAstNode),
155+
src: node.src,
156+
dst: node.dst,
157+
} satisfies Context as T
158+
159+
case 'declaration':
160+
return {
161+
kind: node.kind,
162+
property: node.property,
163+
value: node.value,
164+
important: node.important,
165+
src: node.src,
166+
dst: node.dst,
167+
} satisfies Declaration as T
168+
169+
case 'comment':
170+
return {
171+
kind: node.kind,
172+
value: node.value,
173+
src: node.src,
174+
dst: node.dst,
175+
} satisfies Comment as T
176+
177+
default:
178+
node satisfies never
179+
throw new Error(`Unknown node kind: ${(node as any).kind}`)
180+
}
181+
}
182+
183+
export function cssContext(
184+
ctx: VisitContext<AstNode>,
185+
): VisitContext<AstNode> & { context: Record<string, string | boolean> } {
186+
return {
187+
depth: ctx.depth,
188+
get context() {
189+
let context: Record<string, string | boolean> = {}
190+
for (let child of ctx.path()) {
191+
if (child.kind === 'context') {
192+
Object.assign(context, child.context)
193+
}
194+
}
195+
196+
// Once computed, we never need to compute this again
197+
Object.defineProperty(this, 'context', { value: context })
198+
return context
199+
},
200+
get parent() {
201+
let parent = (this.path().pop() as Extract<AstNode, { nodes: AstNode[] }>) ?? null
202+
203+
// Once computed, we never need to compute this again
204+
Object.defineProperty(this, 'parent', { value: parent })
205+
return parent
206+
},
207+
path() {
208+
return ctx.path().filter((n) => n.kind !== 'context')
209+
},
210+
}
211+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { AstNode, AtRoot, AtRule, Comment, Context, Declaration, StyleRule } from './ast'
2+
3+
export function cloneAstNode<T extends AstNode>(node: T): T {
4+
switch (node.kind) {
5+
case 'rule':
6+
return {
7+
kind: node.kind,
8+
selector: node.selector,
9+
nodes: node.nodes.map(cloneAstNode),
10+
src: node.src,
11+
dst: node.dst,
12+
} satisfies StyleRule as T
13+
14+
case 'at-rule':
15+
return {
16+
kind: node.kind,
17+
name: node.name,
18+
params: node.params,
19+
nodes: node.nodes.map(cloneAstNode),
20+
src: node.src,
21+
dst: node.dst,
22+
} satisfies AtRule as T
23+
24+
case 'at-root':
25+
return {
26+
kind: node.kind,
27+
nodes: node.nodes.map(cloneAstNode),
28+
src: node.src,
29+
dst: node.dst,
30+
} satisfies AtRoot as T
31+
32+
case 'context':
33+
return {
34+
kind: node.kind,
35+
context: { ...node.context },
36+
nodes: node.nodes.map(cloneAstNode),
37+
src: node.src,
38+
dst: node.dst,
39+
} satisfies Context as T
40+
41+
case 'declaration':
42+
return {
43+
kind: node.kind,
44+
property: node.property,
45+
value: node.value,
46+
important: node.important,
47+
src: node.src,
48+
dst: node.dst,
49+
} satisfies Declaration as T
50+
51+
case 'comment':
52+
return {
53+
kind: node.kind,
54+
value: node.value,
55+
src: node.src,
56+
dst: node.dst,
57+
} satisfies Comment as T
58+
59+
default:
60+
node satisfies never
61+
throw new Error(`Unknown node kind: ${(node as any).kind}`)
62+
}
63+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type * as postcss from 'postcss'
2+
import { atRule, comment, decl, styleRule, type AstNode } from './ast'
3+
import type { Source, SourceLocation } from './source'
4+
import { DefaultMap } from '../util/default-map'
5+
6+
const EXCLAMATION_MARK = 0x21
7+
8+
export function fromPostCSSAst(root: postcss.Root): AstNode[] {
9+
let inputMap = new DefaultMap<postcss.Input, Source>((input) => ({
10+
file: input.file ?? input.id ?? null,
11+
code: input.css,
12+
}))
13+
14+
function toSource(node: postcss.ChildNode): SourceLocation | undefined {
15+
let source = node.source
16+
if (!source) return undefined
17+
18+
let input = source.input
19+
if (!input) return undefined
20+
if (source.start === undefined) return undefined
21+
if (source.end === undefined) return undefined
22+
23+
return [inputMap.get(input), source.start.offset, source.end.offset]
24+
}
25+
26+
function transform(
27+
node: postcss.ChildNode,
28+
parent: Extract<AstNode, { nodes: AstNode[] }>['nodes'],
29+
) {
30+
// Declaration
31+
if (node.type === 'decl') {
32+
let astNode = decl(node.prop, node.value, node.important)
33+
astNode.src = toSource(node)
34+
parent.push(astNode)
35+
}
36+
37+
// Rule
38+
else if (node.type === 'rule') {
39+
let astNode = styleRule(node.selector)
40+
astNode.src = toSource(node)
41+
node.each((child) => transform(child, astNode.nodes))
42+
parent.push(astNode)
43+
}
44+
45+
// AtRule
46+
else if (node.type === 'atrule') {
47+
let astNode = atRule(`@${node.name}`, node.params)
48+
astNode.src = toSource(node)
49+
node.each((child) => transform(child, astNode.nodes))
50+
parent.push(astNode)
51+
}
52+
53+
// Comment
54+
else if (node.type === 'comment') {
55+
if (node.text.charCodeAt(0) !== EXCLAMATION_MARK) return
56+
let astNode = comment(node.text)
57+
astNode.src = toSource(node)
58+
parent.push(astNode)
59+
}
60+
61+
// Unknown
62+
else {
63+
node satisfies never
64+
}
65+
}
66+
67+
let ast: AstNode[] = []
68+
root.each((node) => transform(node, ast))
69+
70+
return ast
71+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export * from './ast'
2+
export * from './source'
3+
export { parse } from './parse'
4+
export { fromPostCSSAst } from './from-postcss-ast'
5+
export { toPostCSSAst } from './to-postcss-ast'
6+
export { toCss } from './to-css'

0 commit comments

Comments
 (0)