Last active
August 18, 2019 22:57
-
-
Save tomhodgins/99c75bf647e201ca937ddc3121729a46 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Parse CSS | |
// Originally written by Tab Atkins and friends | |
// homepage: https://github.com/tabatkins/parse-css | |
// license: CC0 | |
// Converted to ES6 and ES Module by Tommy Hodgins (@innovati) | |
// Helpers | |
const char = (str = '') => | |
str.codePointAt(0) | |
const between = (first = 0, num = 0, last = 0) => | |
first <= num && num <= last | |
const digit = (code = 0) => | |
between(char('0'), code, char('9')) | |
const hexdigit = (code = 0) => | |
digit(code) | |
|| between(char('A'), code, char('F')) | |
|| between(char('a'), code, char('f')) | |
const uppercaseletter = (code = 0) => | |
between(char('A'), code, char('Z')) | |
const lowercaseletter = (code = 0) => | |
between(char('a'), code, char('z')) | |
const letter = (code = 0) => | |
uppercaseletter(code) | |
|| lowercaseletter(code) | |
const nonascii = (code = 0) => | |
0x80 <= code | |
const namestartchar = (code = 0) => | |
letter(code) | |
|| nonascii(code) | |
|| code === char('_') | |
const namechar = (code = 0) => | |
namestartchar(code) | |
|| digit(code) | |
|| code === char('-') | |
const nonprintable = (code = 0) => | |
between(0, code, 8) | |
|| code === 0xb | |
|| between(0xe, code, 0x1f) | |
|| code === 0x7f | |
const newline = (code = 0) => | |
code === char('\n') | |
const whitespace = (code = 0) => | |
newline(code) | |
|| code === char('\t') | |
|| code === char(' ') | |
const donothing = () => {} | |
// Tokenizing functions | |
const preprocess = (str = '') => { | |
// Turn a string into an array of code points, | |
// following the preprocessing cleanup rules | |
const codepoints = [] | |
for (let i = 0; i < str.length; i++) { | |
let code = str.charCodeAt(i) | |
if ( | |
code === char('\r') | |
&& str.charCodeAt(i + 1) === char('\n') | |
) { | |
code = char('\n') | |
i++ | |
} | |
if ( | |
code === char('\r') | |
|| code === char('\f') | |
) { | |
code = char('\n') | |
} | |
if (code === 0x0) { | |
code = 0xfffd | |
} | |
if ( | |
between(0xd800, code, 0xdbff) | |
&& between(0xdc00, str.charCodeAt(i + 1), 0xdfff) | |
) { | |
// Decode a surrogate pair into an astral codepoint | |
const lead = code - 0xd800 | |
const trail = str.charCodeAt(i + 1) - 0xdc00 | |
code = Math.pow(2, 20) + lead * Math.pow(2, 10) + trail | |
i++ | |
} | |
codepoints.push(code) | |
} | |
return codepoints | |
} | |
const stringFromCode = (code = 0) => { | |
if (code <= 0xffff) { | |
return String.fromCharCode(code) | |
} | |
// Otherwise, encode astral char as surrogate pair | |
code -= Math.pow(2, 20) | |
const lead = Math.floor(code / Math.pow(2, 10)) + 0xd800 | |
const trail = code % Math.pow(2, 10) + 0xdc00 | |
return String.fromCharCode(lead) + String.fromCharCode(trail) | |
} | |
export const tokenize = (str = '') => { | |
str = preprocess(str) | |
let i = -1 | |
const tokens = [] | |
const maximumallowedcodepoint = 0x10ffff | |
let code | |
// Line number information | |
let line = 0 | |
let column = 0 | |
// The only use of lastLineLength is in reconsume() | |
let lastLineLength = 0 | |
const incrLineno = () => { | |
line += 1 | |
lastLineLength = column | |
column = 0 | |
} | |
const locStart = {line, column} | |
const codepoint = i => { | |
if (str.length <= i) { | |
return -1 | |
} | |
return str[i] | |
} | |
const next = num => { | |
if (num === undefined) { | |
num = 1 | |
} | |
if (3 < num) { | |
throw 'Spec Error: no more than three codepoints of lookahead.' | |
} | |
return codepoint(i + num) | |
} | |
const consume = num => { | |
if (num === undefined) { | |
num = 1 | |
} | |
i += num | |
code = codepoint(i) | |
if (newline(code)) { | |
incrLineno() | |
} else { | |
column += num | |
} | |
return true | |
} | |
const reconsume = () => { | |
i -= 1 | |
if (newline(code)) { | |
line -= 1 | |
column = lastLineLength | |
} else { | |
column -= 1 | |
} | |
locStart.line = line | |
locStart.column = column | |
return true | |
} | |
const eof = codepoint => { | |
if (codepoint === undefined) { | |
codepoint = code | |
} | |
return codepoint === -1 | |
} | |
const parseerror = () => { | |
console.log(`Parse error at index ${i}, processing codepoint 0x${code.toString(16)}.`) | |
return true | |
} | |
const consumeAToken = () => { | |
consumeComments() | |
consume() | |
if (whitespace(code)) { | |
while (whitespace(next())) { | |
consume() | |
} | |
return new WhitespaceToken | |
} | |
else if (code === char('"')) { | |
return consumeAStringToken() | |
} | |
else if (code === char('#')) { | |
if ( | |
namechar(next()) | |
|| areAValidEscape(next(1), next(2)) | |
) { | |
const token = new HashToken() | |
if (wouldStartAnIdentifier(next(1), next(2), next(3))) { | |
token.type = 'id' | |
} | |
token.value = consumeAName() | |
return token | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char('$')) { | |
if (next() === char('=')) { | |
consume() | |
return new SuffixMatchToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char("'")) { | |
return consumeAStringToken() | |
} | |
else if (code === char('(')) { | |
return new OpenParenToken() | |
} | |
else if (code === char(')')) { | |
return new CloseParenToken() | |
} | |
else if (code === char('*')) { | |
if (next() === char('=')) { | |
consume() | |
return new SubstringMatchToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char('+')) { | |
if (startsWithANumber()) { | |
reconsume() | |
return consumeANumericToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char(',')) { | |
return new CommaToken() | |
} | |
else if (code === char('-')) { | |
if (startsWithANumber()) { | |
reconsume() | |
return consumeANumericToken() | |
} | |
else if ( | |
next(1) === char('-') | |
&& next(2) === char('>') | |
) { | |
consume(2) | |
return new CDCToken() | |
} | |
else if (startsWithAnIdentifier()) { | |
reconsume() | |
return consumeAnIdentlikeToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char('.')) { | |
if (startsWithANumber()) { | |
reconsume() | |
return consumeANumericToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char(':')) { | |
return new ColonToken | |
} | |
else if (code === char(';')) { | |
return new SemicolonToken | |
} | |
else if (code === char('<')) { | |
if ( | |
next(1) === char('!') | |
&& next(2) === char('-') | |
&& next(3) === char('-') | |
) { | |
consume(3) | |
return new CDOToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char('@')) { | |
if (wouldStartAnIdentifier(next(1), next(2), next(3))) { | |
return new AtKeywordToken(consumeAName()) | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char('[')) { | |
return new OpenSquareToken() | |
} | |
else if (code === char('\\')) { | |
if (startsWithAValidEscape()) { | |
reconsume() | |
return consumeAnIdentlikeToken() | |
} | |
else { | |
parseerror() | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char(']')) { | |
return new CloseSquareToken() | |
} | |
else if (code === char('^')) { | |
if (next() === char('=')) { | |
consume() | |
return new PrefixMatchToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char('{')) { | |
return new OpenCurlyToken() | |
} | |
else if (code === char('|')) { | |
if (next() === char('=')) { | |
consume() | |
return new DashMatchToken() | |
} | |
else if (next() === char('|')) { | |
consume() | |
return new ColumnToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (code === char('}')) { | |
return new CloseCurlyToken() | |
} | |
else if (code === char('~')) { | |
if (next() === char('=')) { | |
consume() | |
return new IncludeMatchToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
else if (digit(code)) { | |
reconsume() | |
return consumeANumericToken() | |
} | |
else if (namestartchar(code)) { | |
reconsume() | |
return consumeAnIdentlikeToken() | |
} | |
else if (eof()) { | |
return new EOFToken() | |
} | |
else { | |
return new DelimToken(code) | |
} | |
} | |
const consumeComments = () => { | |
while ( | |
next(1) === char('/') | |
&& next(2) === char('*') | |
) { | |
consume(2) | |
while (true) { | |
consume() | |
if ( | |
code === char('*') | |
&& next() === char('/') | |
) { | |
consume() | |
break | |
} | |
else if (eof()) { | |
parseerror() | |
return | |
} | |
} | |
} | |
} | |
const consumeANumericToken = () => { | |
const num = consumeANumber() | |
if (wouldStartAnIdentifier(next(1), next(2), next(3))) { | |
const token = new DimensionToken() | |
token.value = num.value | |
token.repr = num.repr | |
token.type = num.type | |
token.unit = consumeAName() | |
return token | |
} | |
else if (next() === char('%')) { | |
consume() | |
const token = new PercentageToken() | |
token.value = num.value | |
token.repr = num.repr | |
return token | |
} | |
else { | |
const token = new NumberToken() | |
token.value = num.value | |
token.repr = num.repr | |
token.type = num.type | |
return token | |
} | |
} | |
const consumeAnIdentlikeToken = () => { | |
const str = consumeAName() | |
if ( | |
str.toLowerCase() === 'url' | |
&& next() === char('(') | |
) { | |
consume() | |
while ( | |
whitespace(next(1)) | |
&& whitespace(next(2)) | |
) { | |
consume() | |
} | |
if ( | |
next() === char('"') | |
|| next() === char("'") | |
) { | |
return new FunctionToken(str) | |
} | |
else if ( | |
whitespace(next()) | |
&& ( | |
next(2) === char('"') | |
|| next(2) === char("'") | |
) | |
) { | |
return new FunctionToken(str) | |
} | |
else { | |
return consumeAURLToken() | |
} | |
} | |
else if (next() === char('(')) { | |
consume() | |
return new FunctionToken(str) | |
} | |
else { | |
return new IdentToken(str) | |
} | |
} | |
const consumeAStringToken = endingCodePoint => { | |
if (endingCodePoint === undefined) { | |
endingCodePoint = code | |
} | |
let str = '' | |
while (consume()) { | |
if ( | |
code === endingCodePoint | |
|| eof() | |
) { | |
return new StringToken(str) | |
} | |
else if (newline(code)) { | |
parseerror() | |
reconsume() | |
return new BadStringToken() | |
} | |
else if (code === char('\\')) { | |
if (eof(next())) { | |
donothing() | |
} | |
else if (newline(next())) { | |
consume() | |
} | |
else { | |
str += stringFromCode(consumeEscape()) | |
} | |
} | |
else { | |
str += stringFromCode(code) | |
} | |
} | |
} | |
const consumeAURLToken = () => { | |
const token = new URLToken('') | |
while (whitespace(next())) { | |
consume() | |
} | |
if (eof(next())) { | |
return token | |
} | |
while (consume()) { | |
if ( | |
code === char(')') | |
|| eof() | |
) { | |
return token | |
} | |
else if (whitespace(code)) { | |
while (whitespace(next())) { | |
consume() | |
} | |
if ( | |
next() === char(')') | |
|| eof(next()) | |
) { | |
consume() | |
return token | |
} | |
else { | |
consumeTheRemnantsOfABadURL() | |
return new BadURLToken() | |
} | |
} | |
else if ( | |
code === char('"') | |
|| code === char("'") | |
|| code === char('(') | |
|| nonprintable(code) | |
) { | |
parseerror() | |
consumeTheRemnantsOfABadURL() | |
return new BadURLToken() | |
} | |
else if (code === char('\\')) { | |
if (startsWithAValidEscape()) { | |
token.value += stringFromCode(consumeEscape()) | |
} | |
else { | |
parseerror() | |
consumeTheRemnantsOfABadURL() | |
return new BadURLToken() | |
} | |
} | |
else { | |
token.value += stringFromCode(code) | |
} | |
} | |
} | |
const consumeEscape = () => { | |
// Assume the the current character is the \ | |
// and the next code point is not a newline | |
consume() | |
if (hexdigit(code)) { | |
// Consume 1-6 hex digits | |
const digits = [code] | |
for (let total = 0; total < 5; total++) { | |
if (hexdigit(next())) { | |
consume() | |
digits.push(code) | |
} | |
else { | |
break | |
} | |
} | |
if (whitespace(next())) { | |
consume() | |
} | |
let value = parseInt( | |
digits.map(x => String.fromCharCode(x)).join(''), | |
16 | |
) | |
if (maximumallowedcodepoint < value) { | |
value = 0xfffd | |
} | |
return value | |
} | |
else if (eof()) { | |
return 0xfffd | |
} | |
else { | |
return code | |
} | |
} | |
const areAValidEscape = (c1, c2) => { | |
if (c1 !== char('\\')) { | |
return false | |
} | |
if (newline(c2)) { | |
return false | |
} | |
return true | |
} | |
const startsWithAValidEscape = () => | |
areAValidEscape(code, next()) | |
const wouldStartAnIdentifier = (c1, c2, c3) => { | |
if (c1 === char('-')) { | |
return namestartchar(c2) | |
|| c2 === char('-') | |
|| areAValidEscape(c2, c3) | |
} | |
else if (namestartchar(c1)) { | |
return true | |
} | |
else if (c1 === char('\\')) { | |
return areAValidEscape(c1, c2) | |
} | |
else { | |
return false | |
} | |
} | |
const startsWithAnIdentifier = () => | |
wouldStartAnIdentifier(code, next(1), next(2)) | |
const wouldStartANumber = (c1, c2, c3) => { | |
if ( | |
c1 === char('+') | |
|| c1 === char('-') | |
) { | |
if (digit(c2)) { | |
return true | |
} | |
if ( | |
c2 === char('.') | |
&& digit(c3) | |
) { | |
return true | |
} | |
return false | |
} | |
else if (c1 === char('.')) { | |
if (digit(c2)) { | |
return true | |
} | |
return false | |
} | |
else if (digit(c1)) { | |
return true | |
} | |
else { | |
return false | |
} | |
} | |
const startsWithANumber = () => wouldStartANumber(code, next(1), next(2)) | |
const consumeAName = () => { | |
let result = '' | |
while (consume()) { | |
if (namechar(code)) { | |
result += stringFromCode(code) | |
} | |
else if (startsWithAValidEscape()) { | |
result += stringFromCode(consumeEscape()) | |
} | |
else { | |
reconsume() | |
return result | |
} | |
} | |
} | |
const consumeANumber = () => { | |
let repr = [] | |
let type = 'integer' | |
if ( | |
next() === char('+') | |
|| next() === char('-') | |
) { | |
consume() | |
repr += stringFromCode(code) | |
} | |
while (digit(next())) { | |
consume() | |
repr += stringFromCode(code) | |
} | |
if ( | |
next(1) === char('.') | |
&& digit(next(2)) | |
) { | |
consume() | |
repr += stringFromCode(code) | |
consume() | |
repr += stringFromCode(code) | |
type = 'number' | |
while (digit(next())) { | |
consume() | |
repr += stringFromCode(code) | |
} | |
} | |
const c1 = next(1) | |
const c2 = next(2) | |
const c3 = next(3) | |
if ( | |
( | |
c1 === char('E') | |
|| c1 === char('e') | |
) | |
&& digit(c2) | |
) { | |
consume() | |
repr += stringFromCode(code) | |
consume() | |
repr += stringFromCode(code) | |
type = 'number' | |
while (digit(next())) { | |
consume() | |
repr += stringFromCode(code) | |
} | |
} | |
else if ( | |
( | |
c1 === char('E') | |
|| c1 === char('e') | |
) | |
&& ( | |
c2 === char('+') | |
|| c2 === char('-') | |
) | |
&& digit(c3) | |
) { | |
consume() | |
repr += stringFromCode(code) | |
consume() | |
repr += stringFromCode(code) | |
consume() | |
repr += stringFromCode(code) | |
type = 'number' | |
while (digit(next())) { | |
consume() | |
repr += stringFromCode(code) | |
} | |
} | |
const value = convertAStringToANumber(repr) | |
return {type, value, repr} | |
} | |
// CSS's number rules are identical to JS, afaik | |
const convertAStringToANumber = (str = '') => +str | |
const consumeTheRemnantsOfABadURL = () => { | |
while (consume()) { | |
if ( | |
code === char(')') | |
|| eof() | |
) { | |
return | |
} | |
else if (startsWithAValidEscape()) { | |
consumeEscape() | |
donothing() | |
} | |
else { | |
donothing() | |
} | |
} | |
} | |
let iterationCount = 0 | |
while (!eof(next())) { | |
tokens.push(consumeAToken()) | |
iterationCount++ | |
if (str.length * 2 < iterationCount) { | |
return 'I’m infinite-looping!' | |
} | |
} | |
return tokens | |
} | |
// Token objects | |
export function CSSParserToken() { throw 'Abstract Base Class' } | |
CSSParserToken.prototype.toJSON = function() { return {token: this.tokenType} } | |
CSSParserToken.prototype.toString = function() { return this.tokenType } | |
CSSParserToken.prototype.toSource = function() { return '' + this } | |
export function BadStringToken() { return this } | |
BadStringToken.prototype = Object.create(CSSParserToken.prototype) | |
BadStringToken.prototype.tokenType = 'BADSTRING' | |
export function BadURLToken() { return this } | |
BadURLToken.prototype = Object.create(CSSParserToken.prototype) | |
BadURLToken.prototype.tokenType = 'BADURL' | |
export function WhitespaceToken() { return this } | |
WhitespaceToken.prototype = Object.create(CSSParserToken.prototype) | |
WhitespaceToken.prototype.tokenType = 'WHITESPACE' | |
WhitespaceToken.prototype.toString = function() { return 'WS' } | |
WhitespaceToken.prototype.toSource = function() { return ' ' } | |
export function CDOToken() { return this } | |
CDOToken.prototype = Object.create(CSSParserToken.prototype) | |
CDOToken.prototype.tokenType = 'CDO' | |
CDOToken.prototype.toSource = function() { return '<!--' } | |
export function CDCToken() { return this } | |
CDCToken.prototype = Object.create(CSSParserToken.prototype) | |
CDCToken.prototype.tokenType = 'CDC' | |
CDCToken.prototype.toSource = function() { return '-->' } | |
export function ColonToken() { return this } | |
ColonToken.prototype = Object.create(CSSParserToken.prototype) | |
ColonToken.prototype.tokenType = ':' | |
export function SemicolonToken() { return this } | |
SemicolonToken.prototype = Object.create(CSSParserToken.prototype) | |
SemicolonToken.prototype.tokenType = ';' | |
export function CommaToken() { return this } | |
CommaToken.prototype = Object.create(CSSParserToken.prototype) | |
CommaToken.prototype.tokenType = ',' | |
export function GroupingToken() { throw 'Abstract Base Class' } | |
GroupingToken.prototype = Object.create(CSSParserToken.prototype) | |
export function OpenCurlyToken() { | |
this.value = '{' | |
this.mirror = '}' | |
return this | |
} | |
OpenCurlyToken.prototype = Object.create(GroupingToken.prototype) | |
OpenCurlyToken.prototype.tokenType = '{' | |
export function CloseCurlyToken() { | |
this.value = '}' | |
this.mirror = '{' | |
return this | |
} | |
CloseCurlyToken.prototype = Object.create(GroupingToken.prototype) | |
CloseCurlyToken.prototype.tokenType = '}' | |
export function OpenSquareToken() { | |
this.value = '[' | |
this.mirror = ']' | |
return this | |
} | |
OpenSquareToken.prototype = Object.create(GroupingToken.prototype) | |
OpenSquareToken.prototype.tokenType = '[' | |
export function CloseSquareToken() { | |
this.value = ']' | |
this.mirror = '[' | |
return this | |
} | |
CloseSquareToken.prototype = Object.create(GroupingToken.prototype) | |
CloseSquareToken.prototype.tokenType = ']' | |
export function OpenParenToken() { | |
this.value = '(' | |
this.mirror = ')' | |
return this | |
} | |
OpenParenToken.prototype = Object.create(GroupingToken.prototype) | |
OpenParenToken.prototype.tokenType = '(' | |
export function CloseParenToken() { | |
this.value = ')' | |
this.mirror = '(' | |
return this | |
} | |
CloseParenToken.prototype = Object.create(GroupingToken.prototype) | |
CloseParenToken.prototype.tokenType = ')' | |
export function IncludeMatchToken() { return this } | |
IncludeMatchToken.prototype = Object.create(CSSParserToken.prototype) | |
IncludeMatchToken.prototype.tokenType = '~=' | |
export function DashMatchToken() { return this } | |
DashMatchToken.prototype = Object.create(CSSParserToken.prototype) | |
DashMatchToken.prototype.tokenType = '|=' | |
export function PrefixMatchToken() { return this } | |
PrefixMatchToken.prototype = Object.create(CSSParserToken.prototype) | |
PrefixMatchToken.prototype.tokenType = '^=' | |
export function SuffixMatchToken() { return this } | |
SuffixMatchToken.prototype = Object.create(CSSParserToken.prototype) | |
SuffixMatchToken.prototype.tokenType = '$=' | |
export function SubstringMatchToken() { return this } | |
SubstringMatchToken.prototype = Object.create(CSSParserToken.prototype) | |
SubstringMatchToken.prototype.tokenType = '*=' | |
export function ColumnToken() { return this } | |
ColumnToken.prototype = Object.create(CSSParserToken.prototype) | |
ColumnToken.prototype.tokenType = '||' | |
export function EOFToken() { return this } | |
EOFToken.prototype = Object.create(CSSParserToken.prototype) | |
EOFToken.prototype.tokenType = 'EOF' | |
EOFToken.prototype.toSource = function() { return '' } | |
export function DelimToken(code) { | |
this.value = stringFromCode(code) | |
return this | |
} | |
DelimToken.prototype = Object.create(CSSParserToken.prototype) | |
DelimToken.prototype.tokenType = 'DELIM' | |
DelimToken.prototype.toString = function() { return `DELIM(${this.value})` } | |
DelimToken.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.value = this.value | |
return json | |
} | |
DelimToken.prototype.toSource = function() { | |
if (this.value === '\\') { | |
return '\\\n' | |
} | |
else { | |
return this.value | |
} | |
} | |
export function StringValuedToken() { throw 'Abstract Base Class' } | |
StringValuedToken.prototype = Object.create(CSSParserToken.prototype) | |
StringValuedToken.prototype.ASCIIMatch = function(str = '') { | |
return this.value.toLowerCase() === str.toLowerCase() | |
} | |
StringValuedToken.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.value = this.value | |
return json | |
} | |
export function IdentToken(val) { | |
this.value = val | |
} | |
IdentToken.prototype = Object.create(StringValuedToken.prototype) | |
IdentToken.prototype.tokenType = 'IDENT' | |
IdentToken.prototype.toString = function() { return `IDENT(${this.value})` } | |
IdentToken.prototype.toSource = function() { | |
return escapeIdent(this.value) | |
} | |
export function FunctionToken(val) { | |
this.value = val | |
this.mirror = ')' | |
} | |
FunctionToken.prototype = Object.create(StringValuedToken.prototype) | |
FunctionToken.prototype.tokenType = 'FUNCTION' | |
FunctionToken.prototype.toString = function() { return `FUNCTION(${this.value})` } | |
FunctionToken.prototype.toSource = function() { | |
return escapeIdent(this.value) + '(' | |
} | |
export function AtKeywordToken(val) { | |
this.value = val | |
} | |
AtKeywordToken.prototype = Object.create(StringValuedToken.prototype) | |
AtKeywordToken.prototype.tokenType = 'AT-KEYWORD' | |
AtKeywordToken.prototype.toString = function() { return `AT(${this.value})` } | |
AtKeywordToken.prototype.toSource = function() { | |
return '@' + escapeIdent(this.value) | |
} | |
export function HashToken(val) { | |
this.value = val | |
this.type = 'unrestricted' | |
} | |
HashToken.prototype = Object.create(StringValuedToken.prototype) | |
HashToken.prototype.tokenType = 'HASH' | |
HashToken.prototype.toString = function() { return `HASH(${this.value})` } | |
HashToken.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.value = this.value | |
json.type = this.type | |
return json | |
} | |
HashToken.prototype.toSource = function() { | |
if (this.type === 'id') { | |
return '#' + escapeIdent(this.value) | |
} | |
else { | |
return '#' + escapeHash(this.value) | |
} | |
} | |
export function StringToken(val) { | |
this.value = val | |
} | |
StringToken.prototype = Object.create(StringValuedToken.prototype) | |
StringToken.prototype.tokenType = 'STRING' | |
StringToken.prototype.toString = function() { | |
return `"${escapeString(this.value)}"` | |
} | |
export function URLToken(val) { | |
this.value = val | |
} | |
URLToken.prototype = Object.create(StringValuedToken.prototype) | |
URLToken.prototype.tokenType = 'URL' | |
URLToken.prototype.toString = function() { return `URL(${this.value})` } | |
URLToken.prototype.toSource = function() { | |
return `url("${escapeString(this.value)}")` | |
} | |
export function NumberToken() { | |
this.value = null | |
this.type = 'integer' | |
this.repr = '' | |
} | |
NumberToken.prototype = Object.create(CSSParserToken.prototype) | |
NumberToken.prototype.tokenType = 'NUMBER' | |
NumberToken.prototype.toString = function() { | |
if (this.type === 'integer') { | |
return `INT(${this.value})` | |
} | |
return `NUMBER(${this.value})` | |
} | |
NumberToken.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.value = this.value | |
json.type = this.type | |
json.repr = this.repr | |
return json | |
} | |
NumberToken.prototype.toSource = function() { return this.repr } | |
export function PercentageToken() { | |
this.value = null | |
this.repr = '' | |
} | |
PercentageToken.prototype = Object.create(CSSParserToken.prototype) | |
PercentageToken.prototype.tokenType = 'PERCENTAGE' | |
PercentageToken.prototype.toString = function() { return `PERCENTAGE(${this.value})` } | |
PercentageToken.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.value = this.value | |
json.repr = this.repr | |
return json | |
} | |
PercentageToken.prototype.toSource = function() { return this.repr + '%' } | |
export function DimensionToken() { | |
this.value = null | |
this.type = 'integer' | |
this.repr = '' | |
this.unit = '' | |
} | |
DimensionToken.prototype = Object.create(CSSParserToken.prototype) | |
DimensionToken.prototype.tokenType = 'DIMENSION' | |
DimensionToken.prototype.toString = function() { return `DIM(${this.value},${this.unit})` } | |
DimensionToken.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.value = this.value | |
json.type = this.type | |
json.repr = this.repr | |
json.unit = this.unit | |
return json | |
} | |
DimensionToken.prototype.toSource = function() { | |
const source = this.repr | |
let unit = escapeIdent(this.unit) | |
if ( | |
unit[0].toLowerCase() === 'e' | |
&& ( | |
unit[1] === '-' | |
|| digit(unit.charCodeAt(1)) | |
) | |
) { | |
// Unit is ambiguous with scientific notation | |
// Remove the leading "e", replace with escape | |
unit = '\\65 ' + unit.slice(1, unit.length) | |
} | |
return source + unit | |
} | |
// Escaping functions | |
function InvalidCharacterError(message) { | |
this.message = message | |
} | |
InvalidCharacterError.prototype = new Error | |
InvalidCharacterError.prototype.name = 'InvalidCharacterError' | |
const escapeIdent = (str = '') => { | |
str = '' + str | |
let result = '' | |
const firstcode = str.charCodeAt(0) | |
for (let i = 0; i < str.length; i++) { | |
const code = str.charCodeAt(i) | |
if (code === 0x0) { | |
throw new InvalidCharacterError('Invalid character: the input contains U+0000.') | |
} | |
if ( | |
between(0x1, code, 0x1f) | |
|| code === 0x7f | |
|| ( | |
i === 0 | |
&& digit(code) | |
) | |
|| ( | |
i === 1 | |
&& digit(code) | |
&& firstcode === char('-') | |
) | |
) { | |
result += '\\' + code.toString(16) + ' ' | |
} | |
else if ( | |
0x80 <= code | |
|| code === char('-') | |
|| code === char('_') | |
|| digit(code) | |
|| letter(code) | |
) { | |
result += str[i] | |
} | |
else { | |
result += '\\' + str[i] | |
} | |
} | |
return result | |
} | |
const escapeHash = (str = '') => { | |
// Escapes the contents of "unrestricted"-type hash tokens | |
// Won't preserve the ID-ness of "id"-type hash tokens; | |
// use escapeIdent() for that | |
str = '' + str | |
let result = '' | |
for (let i = 0; i < str.length; i++) { | |
const code = str.charCodeAt(i) | |
if (code === 0x0) { | |
throw new InvalidCharacterError('Invalid character: the input contains U+0000.') | |
} | |
if ( | |
0x80 <= code | |
|| code === char('-') | |
|| code === char('_') | |
|| digit(code) | |
|| letter(code) | |
) { | |
result += str[i] | |
} | |
else { | |
result += '\\' + code.toString(16) + ' ' | |
} | |
} | |
return result | |
} | |
const escapeString = (str = '') => { | |
str = '' + str | |
let result = '' | |
for (let i = 0; i < str.length; i++) { | |
const code = str.charCodeAt(i) | |
if (code === 0x0) { | |
throw new InvalidCharacterError('Invalid character: the input contains U+0000.') | |
} | |
if ( | |
between(0x1, code, 0x1f) | |
|| code === 0x7f | |
) { | |
result += '\\' + code.toString(16) + ' ' | |
} | |
else if ( | |
code === char('"') | |
|| code === char('\\') | |
) { | |
result += '\\' + str[i] | |
} | |
else { | |
result += str[i] | |
} | |
} | |
return result | |
} | |
// Token stream | |
function TokenStream(tokens = []) { | |
this.tokens = tokens | |
this.i = -1 | |
} | |
TokenStream.prototype.tokenAt = function(i) { | |
if (i < this.tokens.length) { | |
return this.tokens[i] | |
} | |
return new EOFToken() | |
} | |
TokenStream.prototype.consume = function(num) { | |
if (num === undefined) { | |
num = 1 | |
} | |
this.i += num | |
this.token = this.tokenAt(this.i) | |
return true | |
} | |
TokenStream.prototype.next = function() { | |
return this.tokenAt(this.i + 1) | |
} | |
TokenStream.prototype.reconsume = function() { | |
this.i-- | |
} | |
// Parsing functions | |
const parseerror = (s, msg) => { | |
console.log(`Parse error at token ${s.i}: ${s.token}.\n${msg}`) | |
return true | |
} | |
const consumeAListOfRules = (str, topLevel) => { | |
let rules = [] | |
let rule | |
while (str.consume()) { | |
if (str.token instanceof WhitespaceToken) { | |
continue | |
} | |
else if (str.token instanceof EOFToken) { | |
return rules | |
} | |
else if ( | |
str.token instanceof CDOToken | |
|| str.token instanceof CDCToken | |
) { | |
if (topLevel === 'top-level') { | |
continue | |
} | |
str.reconsume() | |
if (rule = consumeAQualifiedRule(str)) { | |
rules.push(rule) | |
} | |
} | |
else if (str.token instanceof AtKeywordToken) { | |
str.reconsume() | |
if (rule = consumeAnAtRule(str)) { | |
rules.push(rule) | |
} | |
} | |
else { | |
str.reconsume() | |
if (rule = consumeAQualifiedRule(str)) { | |
rules.push(rule) | |
} | |
} | |
} | |
} | |
const consumeAnAtRule = (str = '') => { | |
str.consume() | |
const rule = new AtRule(str.token.value) | |
while (str.consume()) { | |
if ( | |
str.token instanceof SemicolonToken | |
|| str.token instanceof EOFToken | |
) { | |
return rule | |
} | |
else if (str.token instanceof OpenCurlyToken) { | |
rule.value = consumeASimpleBlock(str) | |
return rule | |
} | |
else if ( | |
str.token instanceof SimpleBlock | |
&& str.token.name === '{' | |
) { | |
rule.value = str.token | |
return rule | |
} | |
else { | |
str.reconsume() | |
rule.prelude.push(consumeAComponentValue(str)) | |
} | |
} | |
} | |
const consumeAQualifiedRule = (str = '') => { | |
const rule = new QualifiedRule() | |
while (str.consume()) { | |
if (str.token instanceof EOFToken) { | |
parseerror(str, 'Hit EOF when trying to parse the prelude of a qualified rule.') | |
return | |
} | |
else if (str.token instanceof OpenCurlyToken) { | |
rule.value = consumeASimpleBlock(str) | |
return rule | |
} | |
else if ( | |
str.token instanceof SimpleBlock | |
&& str.token.name === '{' | |
) { | |
rule.value = str.token | |
return rule | |
} | |
else { | |
str.reconsume() | |
rule.prelude.push(consumeAComponentValue(str)) | |
} | |
} | |
} | |
const consumeAListOfDeclarations = (str = '') => { | |
let decls = [] | |
while (str.consume()) { | |
if ( | |
str.token instanceof WhitespaceToken | |
|| str.token instanceof SemicolonToken | |
) { | |
donothing() | |
} | |
else if (str.token instanceof EOFToken) { | |
return decls | |
} | |
else if (str.token instanceof AtKeywordToken) { | |
str.reconsume() | |
decls.push(consumeAnAtRule(str)) | |
} | |
else if (str.token instanceof IdentToken) { | |
let temp = [str.token] | |
while ( | |
!( | |
str.next() instanceof SemicolonToken | |
|| str.next() instanceof EOFToken | |
) | |
) { | |
temp.push(consumeAComponentValue(str)) | |
} | |
let decl | |
if (decl = consumeADeclaration(new TokenStream(temp))) { | |
decls.push(decl) | |
} | |
} | |
else { | |
parseerror(str) | |
str.reconsume() | |
while ( | |
!( | |
str.next() instanceof SemicolonToken | |
|| str.next() instanceof EOFToken | |
) | |
) { | |
consumeAComponentValue(str) | |
} | |
} | |
} | |
} | |
const consumeADeclaration = (str = '') => { | |
// Assumes that the next input token will be an ident token | |
str.consume() | |
const decl = new Declaration(str.token.value) | |
while (str.next() instanceof WhitespaceToken) { | |
str.consume() | |
} | |
if (!(str.next() instanceof ColonToken)) { | |
parseerror(str) | |
return | |
} | |
else { | |
str.consume() | |
} | |
while (!(str.next() instanceof EOFToken)) { | |
decl.value.push(consumeAComponentValue(str)) | |
} | |
let foundImportant = false | |
for (let i = decl.value.length - 1; 0 <= i; i--) { | |
if (decl.value[i] instanceof WhitespaceToken) { | |
continue | |
} | |
else if ( | |
decl.value[i] instanceof IdentToken | |
&& decl.value[i].ASCIIMatch('important') | |
) { | |
foundImportant = true | |
} | |
else if ( | |
foundImportant | |
&& decl.value[i] instanceof DelimToken | |
&& decl.value[i].value === '!' | |
) { | |
decl.value.splice(i, decl.value.length) | |
decl.important = true | |
break | |
} | |
else { | |
break | |
} | |
} | |
return decl | |
} | |
const consumeAComponentValue = (str = '') => { | |
str.consume() | |
if ( | |
str.token instanceof OpenCurlyToken | |
|| str.token instanceof OpenSquareToken | |
|| str.token instanceof OpenParenToken | |
) { | |
return consumeASimpleBlock(str) | |
} | |
if (str.token instanceof FunctionToken) { | |
return consumeAFunction(str) | |
} | |
return str.token | |
} | |
const consumeASimpleBlock = (str = '') => { | |
const mirror = str.token.mirror | |
const block = new SimpleBlock(str.token.value) | |
while (str.consume()) { | |
if ( | |
str.token instanceof EOFToken | |
|| ( | |
str.token instanceof GroupingToken | |
&& str.token.value === mirror | |
) | |
) { | |
return block | |
} | |
else { | |
str.reconsume() | |
block.value.push(consumeAComponentValue(str)) | |
} | |
} | |
} | |
const consumeAFunction = (str = '') => { | |
const func = new Func(str.token.value) | |
while (str.consume()) { | |
if ( | |
str.token instanceof EOFToken | |
|| str.token instanceof CloseParenToken | |
) { | |
return func | |
} | |
else { | |
str.reconsume() | |
func.value.push(consumeAComponentValue(str)) | |
} | |
} | |
} | |
const normalizeInput = input => { | |
if (typeof input === 'string') { | |
return new TokenStream(tokenize(input)) | |
} | |
if (input instanceof TokenStream) { | |
return input | |
} | |
if (input.length !== undefined) { | |
return new TokenStream(input) | |
} | |
else { | |
throw SyntaxError(input) | |
} | |
} | |
export const parseAStylesheet = (str = '') => { | |
str = normalizeInput(str) | |
const sheet = new Stylesheet() | |
sheet.value = consumeAListOfRules(str, 'top-level') | |
return sheet | |
} | |
export const parseAListOfRules = (str = '') => { | |
str = normalizeInput(str) | |
return consumeAListOfRules(str) | |
} | |
export const parseARule = (str = '') => { | |
str = normalizeInput(str) | |
let rule | |
while (str.next() instanceof WhitespaceToken) { | |
str.consume() | |
} | |
if (str.next() instanceof EOFToken) { | |
throw SyntaxError() | |
} | |
if (str.next() instanceof AtKeywordToken) { | |
rule = consumeAnAtRule(str) | |
} | |
else { | |
rule = consumeAQualifiedRule(str) | |
if (!rule) { | |
throw SyntaxError() | |
} | |
} | |
while (str.next() instanceof WhitespaceToken) { | |
str.consume() | |
} | |
if (str.next() instanceof EOFToken) { | |
return rule | |
} | |
throw SyntaxError() | |
} | |
export const parseADeclaration = (str = '') => { | |
str = normalizeInput(str) | |
while (str.next() instanceof WhitespaceToken) { | |
str.consume() | |
} | |
if (!(str.next() instanceof IdentToken)) { | |
throw SyntaxError() | |
} | |
const decl = consumeADeclaration(str) | |
if (decl) { | |
return decl | |
} | |
else { | |
throw SyntaxError() | |
} | |
} | |
export const parseAListOfDeclarations = (str = '') => { | |
str = normalizeInput(str) | |
return consumeAListOfDeclarations(str) | |
} | |
export const parseAComponentValue = (str = '') => { | |
str = normalizeInput(str) | |
while (str.next() instanceof WhitespaceToken) { | |
str.consume() | |
} | |
if (str.next() instanceof EOFToken) { | |
throw SyntaxError() | |
} | |
const val = consumeAComponentValue(str) | |
if (!val) { | |
throw SyntaxError() | |
} | |
while (str.next() instanceof WhitespaceToken) { | |
str.consume() | |
} | |
if (str.next() instanceof EOFToken) { | |
return val | |
} | |
throw SyntaxError() | |
} | |
export const parseAListOfComponentValues = (str = '') => { | |
str = normalizeInput(str) | |
let vals = [] | |
while (true) { | |
const val = consumeAComponentValue(str) | |
if (val instanceof EOFToken) { | |
return vals | |
} | |
else { | |
vals.push(val) | |
} | |
} | |
} | |
export const parseACommaSeparatedListOfComponentValues = (str = '') => { | |
str = normalizeInput(str) | |
let listOfCVLs = [] | |
while (true) { | |
let vals = [] | |
while (true) { | |
const val = consumeAComponentValue(str) | |
if (val instanceof EOFToken) { | |
listOfCVLs.push(vals) | |
return listOfCVLs | |
} | |
else if (val instanceof CommaToken) { | |
listOfCVLs.push(vals) | |
break | |
} | |
else { | |
vals.push(val) | |
} | |
} | |
} | |
} | |
// Parser objects | |
export function CSSParserRule() { throw 'Abstract Base Class' } | |
CSSParserRule.prototype.toString = function(indent) { | |
return JSON.stringify(this, null, indent) | |
} | |
CSSParserRule.prototype.toJSON = function() { | |
return {type: this.type, value: this.value} | |
} | |
export function Stylesheet() { | |
this.value = [] | |
return this | |
} | |
Stylesheet.prototype = Object.create(CSSParserRule.prototype) | |
Stylesheet.prototype.type = 'STYLESHEET' | |
export function AtRule(name) { | |
this.name = name | |
this.prelude = [] | |
this.value = null | |
return this | |
} | |
AtRule.prototype = Object.create(CSSParserRule.prototype) | |
AtRule.prototype.type = 'AT-RULE' | |
AtRule.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.name = this.name | |
json.prelude = this.prelude | |
return json | |
} | |
export function QualifiedRule() { | |
this.prelude = [] | |
this.value = [] | |
return this | |
} | |
QualifiedRule.prototype = Object.create(CSSParserRule.prototype) | |
QualifiedRule.prototype.type = 'QUALIFIED-RULE' | |
QualifiedRule.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.prelude = this.prelude | |
return json | |
} | |
export function Declaration(name) { | |
this.name = name | |
this.value = [] | |
this.important = false | |
return this | |
} | |
Declaration.prototype = Object.create(CSSParserRule.prototype) | |
Declaration.prototype.type = 'DECLARATION' | |
Declaration.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.name = this.name | |
json.important = this.important | |
return json | |
} | |
export function SimpleBlock(type) { | |
this.name = type | |
this.value = [] | |
return this | |
} | |
SimpleBlock.prototype = Object.create(CSSParserRule.prototype) | |
SimpleBlock.prototype.type = 'BLOCK' | |
SimpleBlock.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.name = this.name | |
return json | |
} | |
export function Func(name) { | |
this.name = name | |
this.value = [] | |
return this | |
} | |
Func.prototype = Object.create(CSSParserRule.prototype) | |
Func.prototype.type = 'FUNCTION' | |
Func.prototype.toJSON = function() { | |
const json = this.constructor.prototype.constructor.prototype.toJSON.call(this) | |
json.name = this.name | |
return json | |
} | |
// Canonicalization function | |
export const canonicalize = (rule, grammar, topGrammar) => { | |
let unknownTransformer = () => {} | |
if (grammar === undefined) { | |
grammar = CSSGrammar | |
} | |
if (topGrammar === undefined) { | |
topGrammar = grammar | |
} | |
if (!validateGrammar(grammar)) { | |
return | |
} | |
if (grammar) { | |
if (grammar.stylesheet) { | |
grammar = topGrammar | |
} | |
if (grammar.unknown) { | |
unknownTransformer = grammar.unknown | |
} | |
} | |
const ret = {type: rule.type.toLowerCase()} | |
let unparsedContents | |
let contents | |
let result | |
if (rule.type === 'STYLESHEET') { | |
contents = rule.value | |
} | |
else if (rule.type === 'BLOCK') { | |
unparsedContents = rule.value | |
ret.name = rule.name | |
} | |
else if (rule.type === 'QUALIFIED-RULE') { | |
unparsedContents = rule.value.value | |
ret.prelude = rule.prelude | |
} | |
else if (rule.type === 'AT-RULE') { | |
unparsedContents = rule.value.value | |
ret.name = rule.name | |
ret.prelude = rule.prelude | |
} | |
else if (rule.type === 'DECLARATION') { | |
// I don't do grammar-checking of declarations yet | |
ret.name = rule.name | |
ret.value = rule.value | |
ret.important = rule.important | |
return ret | |
} | |
if (unparsedContents) { | |
if (grammar.declarations) { | |
contents = parseAListOfDeclarations(unparsedContents) | |
} | |
else if (grammar.qualified) { | |
contents = parseAListOfRules(unparsedContents) | |
} | |
} | |
if (!grammar) { | |
return ret | |
} else if (grammar.declarations) { | |
ret.declarations = {} // simple key/value map of declarations | |
ret.rules = [] // in-order list of both decls and at-rules | |
ret.errors = [] | |
for (let i = 0; i < contents.length; i++) { | |
const rule = contents[i] | |
if (rule instanceof Declaration) { | |
const decl = canonicalize(rule, {}, topGrammar) | |
ret.declarations[rule.name] = decl | |
ret.rules.push(decl) | |
} | |
else { // rule is instanceof AtRule | |
const subGrammar = grammar['@' + rule.name] | |
if (subGrammar) { // Rule is valid in this context | |
ret.rules.push( | |
canonicalize(rule, subGrammar, topGrammar) | |
) | |
} | |
else { | |
result = unknownTransformer(rule) | |
if (result) { | |
ret.rules.push(result) | |
} | |
else { | |
ret.errors.push(result) | |
} | |
} | |
} | |
} | |
} | |
else { | |
ret.rules = [] | |
ret.errors = [] | |
for (let i = 0; i < contents.length; i++) { | |
const rule = contents[i] | |
if (rule instanceof QualifiedRule) { | |
ret.rules.push( | |
canonicalize(rule, grammar.qualified, topGrammar) | |
) | |
} | |
else { | |
const subGrammar = grammar['@' + rule.name] | |
if (subGrammar) { // Rule is valid in this context | |
ret.rules.push( | |
canonicalize(rule, subGrammar, topGrammar) | |
) | |
} | |
else { | |
result = unknownTransformer(rule) | |
if (result) { | |
ret.rules.push(result) | |
} | |
else { | |
ret.errors.push(result) | |
} | |
} | |
} | |
} | |
} | |
return ret | |
} | |
// Grammar validation | |
const validateGrammar = grammar => true | |
// CSS Grammar definition | |
export const CSSGrammar = { | |
qualified: {declarations: true}, | |
'@media': {stylesheet: true}, | |
'@keyframes': {qualified: {declarations: true}}, | |
'@font-face': {declarations: true}, | |
'@supports': {stylesheet: true}, | |
'@scope': {stylesheet: true}, | |
'@counter-style': {declarations: true}, | |
'@import': null, | |
'@font-feature-values': { | |
// No qualified rules actually allowed, | |
// but have to declare it one way or the other | |
qualified: true, | |
'@stylistic': {declarations: true}, | |
'@styleset': {declarations: true}, | |
'@character-variants': {declarations: true}, | |
'@swash': {declarations: true}, | |
'@ornaments': {declarations: true}, | |
'@annotation': {declarations: true}, | |
}, | |
'@viewport': {declarations: true}, | |
'@page': { | |
declarations: true, | |
'@top-left-corner': {declarations: true}, | |
'@top-left': {declarations: true}, | |
'@top-center': {declarations: true}, | |
'@top-right': {declarations: true}, | |
'@top-right-corner': {declarations: true}, | |
'@right-top': {declarations: true}, | |
'@right-middle': {declarations: true}, | |
'@right-bottom': {declarations: true}, | |
'@right-bottom-corner': {declarations: true}, | |
'@bottom-right': {declarations: true}, | |
'@bottom-center': {declarations: true}, | |
'@bottom-left': {declarations: true}, | |
'@bottom-left-corner': {declarations: true}, | |
'@left-bottom': {declarations: true}, | |
'@left-center': {declarations: true}, | |
'@left-top': {declarations: true}, | |
}, | |
'@custom-selector': null, | |
'@custom-media': null | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment