export type ValueWordNode = {
kind: 'word'
value: string
}
export type ValueFunctionNode = {
kind: 'function'
value: string
nodes: ValueAstNode[]
}
export type ValueSeparatorNode = {
kind: 'separator'
value: string
}
export type ValueAstNode = ValueWordNode | ValueFunctionNode | ValueSeparatorNode
type ValueParentNode = ValueFunctionNode | null
function word(value: string): ValueWordNode {
return {
kind: 'word',
value,
}
}
function fun(value: string, nodes: ValueAstNode[]): ValueFunctionNode {
return {
kind: 'function',
value: value,
nodes,
}
}
function separator(value: string): ValueSeparatorNode {
return {
kind: 'separator',
value,
}
}
export enum ValueWalkAction {
Continue,
Skip,
Stop,
}
export function walk(
ast: ValueAstNode[],
visit: (
node: ValueAstNode,
utils: {
parent: ValueParentNode
replaceWith(newNode: ValueAstNode | ValueAstNode[]): void
},
) => void | ValueWalkAction,
parent: ValueParentNode = null,
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let status =
visit(node, {
parent,
replaceWith(newNode) {
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
i--
},
}) ?? ValueWalkAction.Continue
if (status === ValueWalkAction.Stop) return
if (status === ValueWalkAction.Skip) continue
if (node.kind === 'function') {
walk(node.nodes, visit, node)
}
}
}
export function toCss(ast: ValueAstNode[]) {
let css = ''
for (const node of ast) {
switch (node.kind) {
case 'word':
case 'separator': {
css += node.value
break
}
case 'function': {
css += node.value + '(' + toCss(node.nodes) + ')'
}
}
}
return css
}
const BACKSLASH = 0x5c
const CLOSE_PAREN = 0x29
const COLON = 0x3a
const COMMA = 0x2c
const DOUBLE_QUOTE = 0x22
const OPEN_PAREN = 0x28
const SINGLE_QUOTE = 0x27
const SPACE = 0x20
const LESS_THAN = 0x3c
const GREATER_THAN = 0x3e
const EQUALS = 0x3d
const SLASH = 0x2f
export function parse(input: string) {
input = input.replaceAll('\r\n', '\n')
let ast: ValueAstNode[] = []
let stack: (ValueFunctionNode | null)[] = []
let parent = null as ValueFunctionNode | null
let buffer = ''
let peekChar
for (let i = 0; i < input.length; i++) {
let currentChar = input.charCodeAt(i)
switch (currentChar) {
case COLON:
case COMMA:
case SPACE:
case SLASH:
case LESS_THAN:
case GREATER_THAN:
case EQUALS: {
if (buffer.length > 0) {
let node = word(buffer)
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
buffer = ''
}
let start = i
let end = i + 1
for (; end < input.length; end++) {
peekChar = input.charCodeAt(end)
if (
peekChar !== COLON &&
peekChar !== COMMA &&
peekChar !== SPACE &&
peekChar !== SLASH &&
peekChar !== LESS_THAN &&
peekChar !== GREATER_THAN &&
peekChar !== EQUALS
) {
break
}
}
i = end - 1
let node = separator(input.slice(start, end))
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
break
}
case SINGLE_QUOTE:
case DOUBLE_QUOTE: {
let start = i
for (let j = i + 1; j < input.length; j++) {
peekChar = input.charCodeAt(j)
if (peekChar === BACKSLASH) {
j += 1
}
else if (peekChar === currentChar) {
i = j
break
}
}
buffer += input.slice(start, i + 1)
break
}
case OPEN_PAREN: {
let node = fun(buffer, [])
buffer = ''
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
stack.push(node)
parent = node
break
}
case CLOSE_PAREN: {
let tail = stack.pop()
if (buffer.length > 0) {
let node = word(buffer)
tail!.nodes.push(node)
buffer = ''
}
if (stack.length > 0) {
parent = stack[stack.length - 1]
} else {
parent = null
}
break
}
default: {
buffer += String.fromCharCode(currentChar)
}
}
}
if (buffer.length > 0) {
ast.push(word(buffer))
}
return ast
}