export type Parsed = { start: number; end: number; value: V }; // Values export type ARGB = number; export type URL = string; export type Angle = number; export interface Unit { value: number; unit: string; } export type Length = Unit<"px" | "dip">; export type Percentage = Unit<"%">; export type LengthPercentage = Length | Percentage; export type Keyword = string; export interface ColorStop { argb: ARGB; offset?: LengthPercentage; } export interface LinearGradient { angle: number; colors: ColorStop[]; } export interface Background { readonly color?: number; readonly image?: URL | LinearGradient; readonly repeat?: BackgroundRepeat; readonly position?: BackgroundPosition; readonly size?: BackgroundSize; } export type BackgroundRepeat = "repeat" | "repeat-x" | "repeat-y" | "no-repeat"; export type BackgroundSize = | "auto" | "cover" | "contain" | { x: LengthPercentage; y: "auto" | LengthPercentage; }; export type HorizontalAlign = "left" | "center" | "right"; export type VerticalAlign = "top" | "center" | "bottom"; export interface HorizontalAlignWithOffset { readonly align: "left" | "right"; readonly offset: LengthPercentage; } export interface VerticalAlignWithOffset { readonly align: "top" | "bottom"; readonly offset: LengthPercentage; } export interface BackgroundPosition { readonly x: HorizontalAlign | HorizontalAlignWithOffset; readonly y: VerticalAlign | VerticalAlignWithOffset; text?: string; } const urlRegEx = /\s*url\((?:('|")([^\1]*)\1|([^\)]*))\)\s*/gy; export function parseURL(text: string, start: number = 0): Parsed { urlRegEx.lastIndex = start; const result = urlRegEx.exec(text); if (!result) { return null; } const end = urlRegEx.lastIndex; const value: URL = result[2] || result[3]; return { start, end, value }; } const hexColorRegEx = /\s*#((?:[0-9A-F]{8})|(?:[0-9A-F]{6})|(?:[0-9A-F]{3}))\s*/giy; export function parseHexColor(text: string, start: number = 0): Parsed { hexColorRegEx.lastIndex = start; const result = hexColorRegEx.exec(text); if (!result) { return null; } const end = hexColorRegEx.lastIndex; let hex = result[1]; let argb; if (hex.length === 8) { argb = parseInt("0x" + hex); } else if (hex.length === 6) { argb = parseInt("0xFF" + hex); } else if (hex.length === 3) { argb = parseInt( "0xFF" + hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] ); } return { start, end, value: argb }; } function rgbaToArgbNumber( r: number, g: number, b: number, a: number = 1 ): number | undefined { if ( r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255 && a >= 0 && a <= 1 ) { return ( Math.round(a * 0xff) * 0x01000000 + r * 0x010000 + g * 0x000100 + b ); } else { return null; } } const rgbColorRegEx = /\s*(rgb\(\s*(\d*)\s*,\s*(\d*)\s*,\s*(\d*)\s*\))/gy; export function parseRGBColor(text: string, start: number = 0): Parsed { rgbColorRegEx.lastIndex = start; const result = rgbColorRegEx.exec(text); if (!result) { return null; } const end = rgbColorRegEx.lastIndex; const value = result[1] && rgbaToArgbNumber( parseInt(result[2]), parseInt(result[3]), parseInt(result[4]) ); return { start, end, value }; } const rgbaColorRegEx = /\s*(rgba\(\s*(\d*)\s*,\s*(\d*)\s*,\s*(\d*)\s*,\s*([01]?\.?\d*)\s*\))/gy; export function parseRGBAColor(text: string, start: number = 0): Parsed { rgbaColorRegEx.lastIndex = start; const result = rgbaColorRegEx.exec(text); if (!result) { return null; } const end = rgbaColorRegEx.lastIndex; const value = rgbaToArgbNumber( parseInt(result[2]), parseInt(result[3]), parseInt(result[4]), parseFloat(result[5]) ); return { start, end, value }; } export function convertHSLToRGBColor( hue: number, saturation: number, lightness: number ): { r: number; g: number; b: number } { // Per formula it will be easier if hue is divided to 60° and saturation to 100 beforehand // https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB hue /= 60; lightness /= 100; let chroma = ((1 - Math.abs(2 * lightness - 1)) * saturation) / 100, X = chroma * (1 - Math.abs((hue % 2) - 1)), // Add lightness match to all RGB components beforehand { m: r, m: g, m: b } = { m: lightness - chroma / 2 }; if (0 <= hue && hue < 1) { r += chroma; g += X; } else if (hue < 2) { r += X; g += chroma; } else if (hue < 3) { g += chroma; b += X; } else if (hue < 4) { g += X; b += chroma; } else if (hue < 5) { r += X; b += chroma; } else if (hue < 6) { r += chroma; b += X; } return { r: Math.round(r * 0xff), g: Math.round(g * 0xff), b: Math.round(b * 0xff), }; } function hslaToArgbNumber( h: number, s: number, l: number, a: number = 1 ): number | undefined { let { r, g, b } = convertHSLToRGBColor(h, s, l); if ( r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255 && a >= 0 && a <= 1 ) { return ( Math.round(a * 0xff) * 0x01000000 + r * 0x010000 + g * 0x000100 + b ); } else { return null; } } const hslColorRegEx = /\s*(hsl\(\s*([\d.]*)\s*,\s*([\d.]*)%\s*,\s*([\d.]*)%\s*\))/gy; export function parseHSLColor(text: string, start: number = 0): Parsed { hslColorRegEx.lastIndex = start; const result = hslColorRegEx.exec(text); if (!result) { return null; } const end = hslColorRegEx.lastIndex; const value = result[1] && hslaToArgbNumber( parseFloat(result[2]), parseFloat(result[3]), parseFloat(result[4]) ); return { start, end, value }; } const hslaColorRegEx = /\s*(hsla\(\s*([\d.]*)\s*,\s*([\d.]*)%\s*,\s*([\d.]*)%\s*,\s*([01]?\.?\d*)\s*\))/gy; export function parseHSLAColor(text: string, start: number = 0): Parsed { hslaColorRegEx.lastIndex = start; const result = hslaColorRegEx.exec(text); if (!result) { return null; } const end = hslaColorRegEx.lastIndex; const value = hslaToArgbNumber( parseFloat(result[2]), parseFloat(result[3]), parseFloat(result[4]), parseFloat(result[5]) ); return { start, end, value }; } export enum colors { transparent = 0x00000000, aliceblue = 0xfff0f8ff, antiquewhite = 0xfffaebd7, aqua = 0xff00ffff, aquamarine = 0xff7fffd4, azure = 0xfff0ffff, beige = 0xfff5f5dc, bisque = 0xffffe4c4, black = 0xff000000, blanchedalmond = 0xffffebcd, blue = 0xff0000ff, blueviolet = 0xff8a2be2, brown = 0xffa52a2a, burlywood = 0xffdeb887, cadetblue = 0xff5f9ea0, chartreuse = 0xff7fff00, chocolate = 0xffd2691e, coral = 0xffff7f50, cornflowerblue = 0xff6495ed, cornsilk = 0xfffff8dc, crimson = 0xffdc143c, cyan = 0xff00ffff, darkblue = 0xff00008b, darkcyan = 0xff008b8b, darkgoldenrod = 0xffb8860b, darkgray = 0xffa9a9a9, darkgreen = 0xff006400, darkgrey = 0xffa9a9a9, darkkhaki = 0xffbdb76b, darkmagenta = 0xff8b008b, darkolivegreen = 0xff556b2f, darkorange = 0xffff8c00, darkorchid = 0xff9932cc, darkred = 0xff8b0000, darksalmon = 0xffe9967a, darkseagreen = 0xff8fbc8f, darkslateblue = 0xff483d8b, darkslategray = 0xff2f4f4f, darkslategrey = 0xff2f4f4f, darkturquoise = 0xff00ced1, darkviolet = 0xff9400d3, deeppink = 0xffff1493, deepskyblue = 0xff00bfff, dimgray = 0xff696969, dimgrey = 0xff696969, dodgerblue = 0xff1e90ff, firebrick = 0xffb22222, floralwhite = 0xfffffaf0, forestgreen = 0xff228b22, fuchsia = 0xffff00ff, gainsboro = 0xffdcdcdc, ghostwhite = 0xfff8f8ff, gold = 0xffffd700, goldenrod = 0xffdaa520, gray = 0xff808080, green = 0xff008000, greenyellow = 0xffadff2f, grey = 0xff808080, honeydew = 0xfff0fff0, hotpink = 0xffff69b4, indianred = 0xffcd5c5c, indigo = 0xff4b0082, ivory = 0xfffffff0, khaki = 0xfff0e68c, lavender = 0xffe6e6fa, lavenderblush = 0xfffff0f5, lawngreen = 0xff7cfc00, lemonchiffon = 0xfffffacd, lightblue = 0xffadd8e6, lightcoral = 0xfff08080, lightcyan = 0xffe0ffff, lightgoldenrodyellow = 0xfffafad2, lightgray = 0xffd3d3d3, lightgreen = 0xff90ee90, lightgrey = 0xffd3d3d3, lightpink = 0xffffb6c1, lightsalmon = 0xffffa07a, lightseagreen = 0xff20b2aa, lightskyblue = 0xff87cefa, lightslategray = 0xff778899, lightslategrey = 0xff778899, lightsteelblue = 0xffb0c4de, lightyellow = 0xffffffe0, lime = 0xff00ff00, limegreen = 0xff32cd32, linen = 0xfffaf0e6, magenta = 0xffff00ff, maroon = 0xff800000, mediumaquamarine = 0xff66cdaa, mediumblue = 0xff0000cd, mediumorchid = 0xffba55d3, mediumpurple = 0xff9370db, mediumseagreen = 0xff3cb371, mediumslateblue = 0xff7b68ee, mediumspringgreen = 0xff00fa9a, mediumturquoise = 0xff48d1cc, mediumvioletred = 0xffc71585, midnightblue = 0xff191970, mintcream = 0xfff5fffa, mistyrose = 0xffffe4e1, moccasin = 0xffffe4b5, navajowhite = 0xffffdead, navy = 0xff000080, oldlace = 0xfffdf5e6, olive = 0xff808000, olivedrab = 0xff6b8e23, orange = 0xffffa500, orangered = 0xffff4500, orchid = 0xffda70d6, palegoldenrod = 0xffeee8aa, palegreen = 0xff98fb98, paleturquoise = 0xffafeeee, palevioletred = 0xffdb7093, papayawhip = 0xffffefd5, peachpuff = 0xffffdab9, peru = 0xffcd853f, pink = 0xffffc0cb, plum = 0xffdda0dd, powderblue = 0xffb0e0e6, purple = 0xff800080, rebeccapurple = 0xff663399, red = 0xffff0000, rosybrown = 0xffbc8f8f, royalblue = 0xff4169e1, saddlebrown = 0xff8b4513, salmon = 0xfffa8072, sandybrown = 0xfff4a460, seagreen = 0xff2e8b57, seashell = 0xfffff5ee, sienna = 0xffa0522d, silver = 0xffc0c0c0, skyblue = 0xff87ceeb, slateblue = 0xff6a5acd, slategray = 0xff708090, slategrey = 0xff708090, snow = 0xfffffafa, springgreen = 0xff00ff7f, steelblue = 0xff4682b4, tan = 0xffd2b48c, teal = 0xff008080, thistle = 0xffd8bfd8, tomato = 0xffff6347, turquoise = 0xff40e0d0, violet = 0xffee82ee, wheat = 0xfff5deb3, white = 0xffffffff, whitesmoke = 0xfff5f5f5, yellow = 0xffffff00, yellowgreen = 0xff9acd32, } export function parseColorKeyword( value, start: number, keyword = parseKeyword(value, start) ): Parsed { if (keyword && keyword.value in colors) { const end = keyword.end; const value = colors[keyword.value]; return { start, end, value }; } return null; } export function parseColor( value: string, start: number = 0, keyword = parseKeyword(value, start) ): Parsed { return ( parseHexColor(value, start) || parseColorKeyword(value, start, keyword) || parseRGBColor(value, start) || parseRGBAColor(value, start) || parseHSLColor(value, start) || parseHSLAColor(value, start) ); } const keywordRegEx = /\s*([a-z][\w\-]*)\s*/giy; function parseKeyword(text: string, start: number = 0): Parsed { keywordRegEx.lastIndex = start; const result = keywordRegEx.exec(text); if (!result) { return null; } const end = keywordRegEx.lastIndex; const value = result[1]; return { start, end, value }; } const backgroundRepeatKeywords = new Set([ "repeat", "repeat-x", "repeat-y", "no-repeat", ]); export function parseRepeat( value: string, start: number = 0, keyword = parseKeyword(value, start) ): Parsed { if (keyword && backgroundRepeatKeywords.has(keyword.value)) { const end = keyword.end; const value = keyword.value; return { start, end, value }; } return null; } const unitRegEx = /\s*([\+\-]?(?:\d+\.\d+|\d+|\.\d+)(?:[eE][\+\-]?\d+)?)([a-zA-Z]+|%)?\s*/gy; export function parseUnit( text: string, start: number = 0 ): Parsed> { unitRegEx.lastIndex = start; const result = unitRegEx.exec(text); if (!result) { return null; } const end = unitRegEx.lastIndex; const value = parseFloat(result[1]); const unit = result[2] || "dip"; return { start, end, value: { value, unit } }; } export function parsePercentageOrLength( text: string, start: number = 0 ): Parsed { const unitResult = parseUnit(text, start); if (unitResult) { const { start, end } = unitResult; const value = unitResult.value; if (value.unit === "%") { value.value /= 100; } else if (!value.unit) { value.unit = "dip"; } else if (value.unit === "px" || value.unit === "dip") { // same } else { return null; } return { start, end, value }; } return null; } const angleUnitsToRadMap: { [unit: string]: ( start: number, end: number, value: number ) => Parsed; } = { deg: (start: number, end: number, deg: number) => ({ start, end, value: (deg / 180) * Math.PI, }), rad: (start: number, end: number, rad: number) => ({ start, end, value: rad, }), grad: (start: number, end: number, grad: number) => ({ start, end, value: (grad / 200) * Math.PI, }), turn: (start: number, end: number, turn: number) => ({ start, end, value: turn * Math.PI * 2, }), }; export function parseAngle(value: string, start: number = 0): Parsed { const angleResult = parseUnit(value, start); if (angleResult) { const { start, end, value } = angleResult; return (angleUnitsToRadMap[value.unit] || ((_, __, ___) => null))( start, end, value.value ); } return null; } const backgroundSizeKeywords = new Set(["auto", "contain", "cover"]); export function parseBackgroundSize( value: string, start: number = 0, keyword = parseKeyword(value, start) ): Parsed { let end = start; if (keyword && backgroundSizeKeywords.has(keyword.value)) { end = keyword.end; const value = <"auto" | "cover" | "contain">keyword.value; return { start, end, value }; } // Parse one or two lengths... the other will be "auto" const firstLength = parsePercentageOrLength(value, end); if (firstLength) { end = firstLength.end; const secondLength = parsePercentageOrLength(value, firstLength.end); if (secondLength) { end = secondLength.end; return { start, end, value: { x: firstLength.value, y: secondLength.value }, }; } else { return { start, end, value: { x: firstLength.value, y: "auto" } }; } } return null; } const backgroundPositionKeywords = Object.freeze( new Set(["left", "right", "top", "bottom", "center"]) ); const backgroundPositionKeywordsDirection: { [align: string]: "x" | "center" | "y"; } = { left: "x", right: "x", center: "center", top: "y", bottom: "y", }; export function parseBackgroundPosition( text: string, start: number = 0, keyword = parseKeyword(text, start) ): Parsed { function formatH( align: Parsed, offset: Parsed ) { if (align.value === "center") { return "center"; } if (offset && offset.value.value !== 0) { return { align: align.value, offset: offset.value }; } return align.value; } function formatV( align: Parsed, offset: Parsed ) { if (align.value === "center") { return "center"; } if (offset && offset.value.value !== 0) { return { align: align.value, offset: offset.value }; } return align.value; } let end = start; if (keyword && backgroundPositionKeywords.has(keyword.value)) { end = keyword.end; let firstDirection = backgroundPositionKeywordsDirection[keyword.value]; const firstLength = firstDirection !== "center" && parsePercentageOrLength(text, end); if (firstLength) { end = firstLength.end; } const secondKeyword = parseKeyword(text, end); if ( secondKeyword && backgroundPositionKeywords.has(secondKeyword.value) ) { end = secondKeyword.end; let secondDirection = backgroundPositionKeywordsDirection[secondKeyword.end]; if ( firstDirection === secondDirection && firstDirection !== "center" ) { return null; // Reject pair of both horizontal or both vertical alignments. } const secondLength = secondDirection !== "center" && parsePercentageOrLength(text, end); if (secondLength) { end = secondLength.end; } if ( (firstDirection === secondDirection && secondDirection === "center") || firstDirection === "x" || secondDirection === "y" ) { return { start, end, value: { x: formatH( >keyword, firstLength ), y: formatV( >secondKeyword, secondLength ), }, }; } else { return { start, end, value: { x: formatH( >secondKeyword, secondLength ), y: formatV(>keyword, firstLength), }, }; } } else { if (firstDirection === "center") { return { start, end, value: { x: "center", y: "center" } }; } else if (firstDirection === "x") { return { start, end, value: { x: formatH( >keyword, firstLength ), y: "center", }, }; } else { return { start, end, value: { x: "center", y: formatV(>keyword, firstLength), }, }; } } } else { const firstLength = parsePercentageOrLength(text, end); if (firstLength) { end = firstLength.end; const secondLength = parsePercentageOrLength(text, end); if (secondLength) { end = secondLength.end; return { start, end, value: { x: { align: "left", offset: firstLength.value }, y: { align: "top", offset: secondLength.value }, }, }; } else { return { start, end, value: { x: { align: "left", offset: firstLength.value }, y: "center", }, }; } } else { return null; } } } const directionRegEx = /\s*to\s*(left|right|top|bottom)\s*(left|right|top|bottom)?\s*/gy; const sideDirections = { top: (Math.PI * 0) / 2, right: (Math.PI * 1) / 2, bottom: (Math.PI * 2) / 2, left: (Math.PI * 3) / 2, }; const cornerDirections = { top: { right: (Math.PI * 1) / 4, left: (Math.PI * 7) / 4, }, right: { top: (Math.PI * 1) / 4, bottom: (Math.PI * 3) / 4, }, bottom: { right: (Math.PI * 3) / 4, left: (Math.PI * 5) / 4, }, left: { top: (Math.PI * 7) / 4, bottom: (Math.PI * 5) / 4, }, }; function parseDirection(text: string, start: number = 0): Parsed { directionRegEx.lastIndex = start; const result = directionRegEx.exec(text); if (!result) { return null; } const end = directionRegEx.lastIndex; const firstDirection = result[1]; if (result[2]) { const secondDirection = result[2]; const value = cornerDirections[firstDirection][secondDirection]; return value === undefined ? null : { start, end, value }; } else { return { start, end, value: sideDirections[firstDirection] }; } } const openingBracketRegEx = /\s*\(\s*/gy; const closingBracketRegEx = /\s*\)\s*/gy; const closingBracketOrCommaRegEx = /\s*(\)|,)\s*/gy; function parseArgumentsList( text: string, start: number, argument: (value: string, lastIndex: number, index: number) => Parsed ): Parsed[]> { openingBracketRegEx.lastIndex = start; const openingBracket = openingBracketRegEx.exec(text); if (!openingBracket) { return null; } let end = openingBracketRegEx.lastIndex; const value: Parsed[] = []; closingBracketRegEx.lastIndex = end; const closingBracket = closingBracketRegEx.exec(text); if (closingBracket) { return { start, end, value }; } for (let index = 0; true; index++) { const arg = argument(text, end, index); if (!arg) { return null; } end = arg.end; value.push(arg); closingBracketOrCommaRegEx.lastIndex = end; const closingBracketOrComma = closingBracketOrCommaRegEx.exec(text); if (closingBracketOrComma) { end = closingBracketOrCommaRegEx.lastIndex; if (closingBracketOrComma[1] === ",") { continue; } else if (closingBracketOrComma[1] === ")") { return { start, end, value }; } } else { return null; } } } export function parseColorStop( text: string, start: number = 0 ): Parsed { const color = parseColor(text, start); if (!color) { return null; } let end = color.end; const offset = parsePercentageOrLength(text, end); if (offset) { end = offset.end; return { start, end, value: { argb: color.value, offset: offset.value }, }; } return { start, end, value: { argb: color.value } }; } const linearGradientStartRegEx = /\s*linear-gradient\s*/gy; export function parseLinearGradient( text: string, start: number = 0 ): Parsed { linearGradientStartRegEx.lastIndex = start; const lgs = linearGradientStartRegEx.exec(text); if (!lgs) { return null; } let end = linearGradientStartRegEx.lastIndex; let angle = Math.PI; const colors: ColorStop[] = []; const parsedArgs = parseArgumentsList( text, end, (text, start, index) => { if (index === 0) { // First arg can be gradient direction const angleArg = parseAngle(text, start) || parseDirection(text, start); if (angleArg) { angle = angleArg.value; return angleArg; } } const colorStop = parseColorStop(text, start); if (colorStop) { colors.push(colorStop.value); return colorStop; } return null; } ); if (!parsedArgs) { return null; } end = parsedArgs.end; return { start, end, value: { angle, colors } }; } const slashRegEx = /\s*(\/)\s*/gy; function parseSlash(text: string, start: number): Parsed<"/"> { slashRegEx.lastIndex = start; const slash = slashRegEx.exec(text); if (!slash) { return null; } const end = slashRegEx.lastIndex; return { start, end, value: "/" }; } export function parseBackground( text: string, start: number = 0 ): Parsed { const value: any = {}; let end = start; while (end < text.length) { const keyword = parseKeyword(text, end); const color = parseColor(text, end, keyword); if (color) { value.color = color.value; end = color.end; continue; } const repeat = parseRepeat(text, end, keyword); if (repeat) { value.repeat = repeat.value; end = repeat.end; continue; } const position = parseBackgroundPosition(text, end, keyword); if (position) { position.value.text = text.substring(position.start, position.end); value.position = position.value; end = position.end; const slash = parseSlash(text, end); if (slash) { end = slash.end; const size = parseBackgroundSize(text, end); if (!size) { // Found / but no proper size following return null; } value.size = size.value; end = size.end; } continue; } const url = parseURL(text, end); if (url) { value.image = url.value; end = url.end; continue; } const gradient = parseLinearGradient(text, end); if (gradient) { value.image = gradient.value; end = gradient.end; continue; } return null; } return { start, end, value }; } // Selectors export type Combinator = "+" | "~" | ">" | " "; export interface UniversalSelector { type: "*"; } export interface TypeSelector { type: ""; identifier: string; } export interface ClassSelector { type: "."; identifier: string; } export interface IdSelector { type: "#"; identifier: string; } export interface PseudoClassSelector { type: ":"; identifier: string; } export type AttributeSelectorTest = | "=" | "^=" | "$=" | "*=" | "=" | "~=" | "|="; export interface AttributeSelector { type: "[]"; property: string; test?: AttributeSelectorTest; value?: string; } export type SimpleSelector = | UniversalSelector | TypeSelector | ClassSelector | IdSelector | PseudoClassSelector | AttributeSelector; export type SimpleSelectorSequence = SimpleSelector[]; export type SelectorCombinatorPair = [SimpleSelectorSequence, Combinator]; export type Selector = SelectorCombinatorPair[]; const universalSelectorRegEx = /\*/gy; export function parseUniversalSelector( text: string, start: number = 0 ): Parsed { universalSelectorRegEx.lastIndex = start; const result = universalSelectorRegEx.exec(text); if (!result) { return null; } const end = universalSelectorRegEx.lastIndex; return { start, end, value: { type: "*" } }; } const simpleIdentifierSelectorRegEx = /(#|\.|:|\b)((?:[\w_-]|\\.)(?:[\w\d_-]|\\.)*)/guy; const unicodeEscapeRegEx = /\\([0-9a-fA-F]{1,5}\s|[0-9a-fA-F]{6})/g; export function parseSimpleIdentifierSelector( text: string, start: number = 0 ): Parsed { simpleIdentifierSelectorRegEx.lastIndex = start; const result = simpleIdentifierSelectorRegEx.exec( text.replace( unicodeEscapeRegEx, (_, c) => "\\" + String.fromCodePoint(parseInt(c.trim(), 16)) ) ); if (!result) { return null; } const end = simpleIdentifierSelectorRegEx.lastIndex; const type = <"#" | "." | ":" | "">result[1]; const identifier: string = result[2].replace(/\\/g, ""); const value = < TypeSelector | ClassSelector | IdSelector | PseudoClassSelector >{ type, identifier }; return { start, end, value }; } const attributeSelectorRegEx = /\[\s*([_-\w][_-\w\d]*)\s*(?:(=|\^=|\$=|\*=|\~=|\|=)\s*(?:([_-\w][_-\w\d]*)|"((?:[^\\"]|\\(?:"|n|r|f|\\|0-9a-f))*)"|'((?:[^\\']|\\(?:'|n|r|f|\\|0-9a-f))*)')\s*)?\]/gy; export function parseAttributeSelector( text: string, start: number ): Parsed { attributeSelectorRegEx.lastIndex = start; const result = attributeSelectorRegEx.exec(text); if (!result) { return null; } const end = attributeSelectorRegEx.lastIndex; const property = result[1]; if (result[2]) { const test = result[2]; const value = result[3] || result[4] || result[5]; return { start, end, value: { type: "[]", property, test, value } }; } return { start, end, value: { type: "[]", property } }; } export function parseSimpleSelector( text: string, start: number = 0 ): Parsed { return ( parseUniversalSelector(text, start) || parseSimpleIdentifierSelector(text, start) || parseAttributeSelector(text, start) ); } export function parseSimpleSelectorSequence( text: string, start: number ): Parsed { let simpleSelector = parseSimpleSelector(text, start); if (!simpleSelector) { return null; } let end = simpleSelector.end; let value = []; while (simpleSelector) { value.push(simpleSelector.value); end = simpleSelector.end; simpleSelector = parseSimpleSelector(text, end); } return { start, end, value }; } const combinatorRegEx = /\s*(\+|~|>)?\s*/gy; export function parseCombinator( text: string, start: number = 0 ): Parsed { combinatorRegEx.lastIndex = start; const result = combinatorRegEx.exec(text); if (!result) { return null; } const end = combinatorRegEx.lastIndex; const value = result[1] || " "; return { start, end, value }; } const whiteSpaceRegEx = /\s*/gy; export function parseSelector( text: string, start: number = 0 ): Parsed { let end = start; whiteSpaceRegEx.lastIndex = end; const leadingWhiteSpace = whiteSpaceRegEx.exec(text); if (leadingWhiteSpace) { end = whiteSpaceRegEx.lastIndex; } let value = []; let combinator: Parsed; let expectSimpleSelector = true; // Must have at least one let pair: SelectorCombinatorPair; do { const simpleSelectorSequence = parseSimpleSelectorSequence(text, end); if (!simpleSelectorSequence) { if (expectSimpleSelector) { return null; } else { break; } } end = simpleSelectorSequence.end; if (combinator) { pair[1] = combinator.value; } pair = [simpleSelectorSequence.value, undefined]; value.push(pair); combinator = parseCombinator(text, end); if (combinator) { end = combinator.end; } expectSimpleSelector = combinator && combinator.value !== " "; // Simple selector must follow non trailing white space combinator } while (combinator); return { start, end, value }; } export interface Stylesheet { rules: Rule[]; } export type Rule = QualifiedRule | AtRule; export interface AtRule { type: "at-rule"; name: string; prelude: InputToken[]; block: SimpleBlock; } export interface QualifiedRule { type: "qualified-rule"; prelude: InputToken[]; block: SimpleBlock; } const whitespaceRegEx = /[\s\t\n\r\f]*/gmy; const singleQuoteStringRegEx = /'((?:[^\n\r\f\']|\\(?:\$|\n|[0-9a-fA-F]{1,6}\s?))*)(:?'|$)/gmy; // Besides $n, parse escape const doubleQuoteStringRegEx = /"((?:[^\n\r\f\"]|\\(?:\$|\n|[0-9a-fA-F]{1,6}\s?))*)(:?"|$)/gmy; // Besides $n, parse escape const commentRegEx = /(\/\*(?:[^\*]|\*[^\/])*\*\/)/gmy; const numberRegEx = /[\+\-]?(?:\d+\.\d+|\d+|\.\d+)(?:[eE][\+\-]?\d+)?/gmy; const nameRegEx = /-?(?:(?:[a-zA-Z_]|[^\x00-\x7F]|\\(?:\$|\n|[0-9a-fA-F]{1,6}\s?))(?:[a-zA-Z_0-9\-]*|\\(?:\$|\n|[0-9a-fA-F]{1,6}\s?))*)/gmy; // const nonQuoteURLRegEx = /(:?[^\)\s\t\n\r\f\'\"\(]|\\(?:\$|\n|[0-9a-fA-F]{1,6}\s?))*/gym; // TODO: non-printable code points omitted type InputToken = | "(" | ")" | "{" | "}" | "[" | "]" | ":" | ";" | "," | " " | "^=" | "|=" | "$=" | "*=" | "~=" | "" | undefined | /* */ InputTokenObject | FunctionInputToken | FunctionToken | SimpleBlock | AtKeywordToken; export const enum TokenObjectType { /** * */ string = 1, /** * */ delim = 2, /** * */ number = 3, /** * */ percentage = 4, /** * */ dimension = 5, /** * */ ident = 6, /** * */ url = 7, /** * * This is a token indicating a function's leading: ( */ functionToken = 8, /** * */ simpleBlock = 9, /** * */ comment = 10, /** * */ atKeyword = 11, /** * */ hash = 12, /** * * This is a complete consumed function: ([ [, ]*])")" */ function = 14, } interface InputTokenObject { type: TokenObjectType; text: string; } /** * This is a "(" token. */ interface FunctionInputToken extends InputTokenObject { name: string; } /** * This is a completely parsed function like "([component [, component]*])". */ interface FunctionToken extends FunctionInputToken { components: any[]; } interface SimpleBlock extends InputTokenObject { associatedToken: InputToken; values: InputToken[]; } interface AtKeywordToken extends InputTokenObject {} /** * CSS parser following relatively close: * CSS Syntax Module Level 3 * https://www.w3.org/TR/css-syntax-3/ */ export class CSS3Parser { private nextInputCodePointIndex = 0; private reconsumedInputToken: InputToken; private topLevelFlag: boolean; constructor(private text: string) {} /** * For testing purposes. * This method allows us to run and assert the proper working of the tokenizer. */ tokenize(): InputToken[] { let tokens: InputToken[] = []; let inputToken: InputToken; do { inputToken = this.consumeAToken(); tokens.push(inputToken); } while (inputToken); return tokens; } /** * 4.3.1. Consume a token * https://www.w3.org/TR/css-syntax-3/#consume-a-token */ private consumeAToken(): InputToken { if (this.reconsumedInputToken) { let result = this.reconsumedInputToken; this.reconsumedInputToken = null; return result; } const char = this.text[this.nextInputCodePointIndex]; switch (char) { case '"': return this.consumeAStringToken(); case "'": return this.consumeAStringToken(); case "(": case ")": case ",": case ":": case ";": case "[": case "]": case "{": case "}": this.nextInputCodePointIndex++; return char; case "#": return this.consumeAHashToken() || this.consumeADelimToken(); case " ": case "\t": case "\n": case "\r": case "\f": return this.consumeAWhitespace(); case "@": return this.consumeAtKeyword() || this.consumeADelimToken(); // TODO: Only if this is valid escape, otherwise it is a parse error case "\\": return ( this.consumeAnIdentLikeToken() || this.consumeADelimToken() ); case "0": case "1": case "2": case "3": case "4": case "5": case "6": case "7": case "8": case "9": return this.consumeANumericToken(); case "u": case "U": if (this.text[this.nextInputCodePointIndex + 1] === "+") { const thirdChar = this.text[ this.nextInputCodePointIndex + 2 ]; if ( (thirdChar >= "0" && thirdChar <= "9") || thirdChar === "?" ) { // TODO: Handle unicode stuff such as U+002B throw new Error("Unicode tokens not supported!"); } } return ( this.consumeAnIdentLikeToken() || this.consumeADelimToken() ); case "$": case "*": case "^": case "|": case "~": return this.consumeAMatchToken() || this.consumeADelimToken(); case "-": return ( this.consumeANumericToken() || this.consumeAnIdentLikeToken() || this.consumeCDC() || this.consumeADelimToken() ); case "+": case ".": return this.consumeANumericToken() || this.consumeADelimToken(); case "/": return this.consumeAComment() || this.consumeADelimToken(); case "<": return this.consumeCDO() || this.consumeADelimToken(); case undefined: return undefined; default: return ( this.consumeAnIdentLikeToken() || this.consumeADelimToken() ); } } private consumeADelimToken(): InputToken { return { type: TokenObjectType.delim, text: this.text[this.nextInputCodePointIndex++], }; } private consumeAWhitespace(): InputToken { whitespaceRegEx.lastIndex = this.nextInputCodePointIndex; whitespaceRegEx.exec(this.text); this.nextInputCodePointIndex = whitespaceRegEx.lastIndex; return " "; } private consumeAHashToken(): InputTokenObject { this.nextInputCodePointIndex++; let hashName = this.consumeAName(); if (hashName) { return { type: TokenObjectType.hash, text: "#" + hashName.text }; } this.nextInputCodePointIndex--; return null; } private consumeCDO(): "" | null { if (this.text.substr(this.nextInputCodePointIndex, 3) === "-->") { this.nextInputCodePointIndex += 3; return "-->"; } return null; } private consumeAMatchToken(): "*=" | "$=" | "|=" | "~=" | "^=" | null { if (this.text[this.nextInputCodePointIndex + 1] === "=") { const token = this.text.substr(this.nextInputCodePointIndex, 2); this.nextInputCodePointIndex += 2; return <"*=" | "$=" | "|=" | "~=" | "^=">token; } return null; } /** * 4.3.2. Consume a numeric token * https://www.w3.org/TR/css-syntax-3/#consume-a-numeric-token */ private consumeANumericToken(): InputToken { numberRegEx.lastIndex = this.nextInputCodePointIndex; const result = numberRegEx.exec(this.text); if (!result) { return null; } this.nextInputCodePointIndex = numberRegEx.lastIndex; if (this.text[this.nextInputCodePointIndex] === "%") { return { type: TokenObjectType.percentage, text: result[0] }; // TODO: Push the actual number and unit here... } const name = this.consumeAName(); if (name) { return { type: TokenObjectType.dimension, text: result[0] + name.text, }; } return { type: TokenObjectType.number, text: result[0] }; } /** * 4.3.3. Consume an ident-like token * https://www.w3.org/TR/css-syntax-3/#consume-an-ident-like-token */ private consumeAnIdentLikeToken(): InputToken { const name = this.consumeAName(); if (!name) { return null; } if (this.text[this.nextInputCodePointIndex] === "(") { this.nextInputCodePointIndex++; if (name.text.toLowerCase() === "url") { return this.consumeAURLToken(); } return { type: TokenObjectType.functionToken, name: name.text, text: name.text + "(", }; } return name; } /** * 4.3.4. Consume a string token * https://www.w3.org/TR/css-syntax-3/#consume-a-string-token */ private consumeAStringToken(): InputTokenObject { const char = this.text[this.nextInputCodePointIndex]; let result: RegExpExecArray; if (char === "'") { singleQuoteStringRegEx.lastIndex = this.nextInputCodePointIndex; result = singleQuoteStringRegEx.exec(this.text); if (!result) { return null; } this.nextInputCodePointIndex = singleQuoteStringRegEx.lastIndex; } else if (char === '"') { doubleQuoteStringRegEx.lastIndex = this.nextInputCodePointIndex; result = doubleQuoteStringRegEx.exec(this.text); if (!result) { return null; } this.nextInputCodePointIndex = doubleQuoteStringRegEx.lastIndex; } // TODO: Handle bad-string. // TODO: Perform string escaping. return { type: TokenObjectType.string, text: result[0] }; } /** * 4.3.5. Consume a url token * https://www.w3.org/TR/css-syntax-3/#consume-a-url-token */ private consumeAURLToken(): InputToken { const start = this.nextInputCodePointIndex - 3 /* url */ - 1; /* ( */ const urlToken: InputToken = { type: TokenObjectType.url, text: undefined, }; this.consumeAWhitespace(); if (this.nextInputCodePointIndex >= this.text.length) { return urlToken; } const nextInputCodePoint = this.text[this.nextInputCodePointIndex]; if (nextInputCodePoint === '"' || nextInputCodePoint === "'") { const stringToken = this.consumeAStringToken(); // TODO: Handle bad-string. // TODO: Set value instead. urlToken.text = stringToken.text; this.consumeAWhitespace(); if ( this.text[this.nextInputCodePointIndex] === ")" || this.nextInputCodePointIndex >= this.text.length ) { this.nextInputCodePointIndex++; const end = this.nextInputCodePointIndex; urlToken.text = this.text.substring(start, end); return urlToken; } else { // TODO: Handle bad-url. return null; } } while (this.nextInputCodePointIndex < this.text.length) { const char = this.text[this.nextInputCodePointIndex++]; switch (char) { case ")": return urlToken; case " ": case "\t": case "\n": case "\r": case "\f": this.consumeAWhitespace(); if (this.text[this.nextInputCodePointIndex] === ")") { this.nextInputCodePointIndex++; return urlToken; } else { // TODO: Bar url! Consume remnants. return null; } case '"': case "'": // TODO: Parse error! Bar url! Consume remnants. return null; case "\\": // TODO: Escape! throw new Error("Escaping not yet supported!"); default: // TODO: Non-printable chars - error. urlToken.text += char; } } return urlToken; } /** * 4.3.11. Consume a name * https://www.w3.org/TR/css-syntax-3/#consume-a-name */ private consumeAName(): InputTokenObject { nameRegEx.lastIndex = this.nextInputCodePointIndex; const result = nameRegEx.exec(this.text); if (!result) { return null; } this.nextInputCodePointIndex = nameRegEx.lastIndex; // TODO: Perform string escaping. return { type: TokenObjectType.ident, text: result[0] }; } private consumeAtKeyword(): InputTokenObject { this.nextInputCodePointIndex++; let name = this.consumeAName(); if (name) { return { type: TokenObjectType.atKeyword, text: name.text }; } this.nextInputCodePointIndex--; return null; } private consumeAComment(): InputToken { if (this.text[this.nextInputCodePointIndex + 1] === "*") { commentRegEx.lastIndex = this.nextInputCodePointIndex; const result = commentRegEx.exec(this.text); if (!result) { return null; // TODO: Handle } this.nextInputCodePointIndex = commentRegEx.lastIndex; // The CSS spec tokenizer does not emmit comment tokens return this.consumeAToken(); } return null; } private reconsumeTheCurrentInputToken(currentInputToken: InputToken) { this.reconsumedInputToken = currentInputToken; } /** * 5.3.1. Parse a stylesheet * https://www.w3.org/TR/css-syntax-3/#parse-a-stylesheet */ public parseAStylesheet(): Stylesheet { this.topLevelFlag = true; const stylesheet: Stylesheet = { rules: this.consumeAListOfRules(), }; return stylesheet; } /** * 5.4.1. Consume a list of rules * https://www.w3.org/TR/css-syntax-3/#consume-a-list-of-rules */ public consumeAListOfRules(): Rule[] { const rules: Rule[] = []; let inputToken: InputToken; while ((inputToken = this.consumeAToken())) { switch (inputToken) { case " ": continue; case "": if (this.topLevelFlag) { continue; } this.reconsumeTheCurrentInputToken(inputToken); const atRule = this.consumeAnAtRule(); if (atRule) { rules.push(atRule); } continue; } if ( (inputToken).type === TokenObjectType.atKeyword ) { this.reconsumeTheCurrentInputToken(inputToken); const atRule = this.consumeAnAtRule(); if (atRule) { rules.push(atRule); } continue; } this.reconsumeTheCurrentInputToken(inputToken); const qualifiedRule = this.consumeAQualifiedRule(); if (qualifiedRule) { rules.push(qualifiedRule); } } return rules; } /** * 5.4.2. Consume an at-rule * https://www.w3.org/TR/css-syntax-3/#consume-an-at-rule */ public consumeAnAtRule(): AtRule { let inputToken = this.consumeAToken(); const atRule: AtRule = { type: "at-rule", name: (inputToken).text, prelude: [], block: undefined, }; while ((inputToken = this.consumeAToken())) { if (inputToken === ";") { return atRule; } else if (inputToken === "{") { atRule.block = this.consumeASimpleBlock(inputToken); return atRule; } else if ( (inputToken).type === TokenObjectType.simpleBlock && (inputToken).associatedToken === "{" ) { atRule.block = inputToken; return atRule; } this.reconsumeTheCurrentInputToken(inputToken); const component = this.consumeAComponentValue(); if (component) { atRule.prelude.push(component); } } return atRule; } /** * 5.4.3. Consume a qualified rule * https://www.w3.org/TR/css-syntax-3/#consume-a-qualified-rule */ public consumeAQualifiedRule(): QualifiedRule { const qualifiedRule: QualifiedRule = { type: "qualified-rule", prelude: [], block: undefined, }; let inputToken: InputToken; while ((inputToken = this.consumeAToken())) { if (inputToken === "{") { let block = this.consumeASimpleBlock(inputToken); qualifiedRule.block = block; return qualifiedRule; } else if ( (inputToken).type === TokenObjectType.simpleBlock ) { const simpleBlock: SimpleBlock = inputToken; if (simpleBlock.associatedToken === "{") { qualifiedRule.block = simpleBlock; return qualifiedRule; } } this.reconsumeTheCurrentInputToken(inputToken); const componentValue = this.consumeAComponentValue(); if (componentValue) { qualifiedRule.prelude.push(componentValue); } } // TODO: This is a parse error, log parse errors! return null; } /** * 5.4.6. Consume a component value * https://www.w3.org/TR/css-syntax-3/#consume-a-component-value */ private consumeAComponentValue(): InputToken { // const inputToken = this.consumeAToken(); const inputToken = this.consumeAToken(); switch (inputToken) { case "{": case "[": case "(": this.nextInputCodePointIndex++; return this.consumeASimpleBlock(inputToken); } if ( typeof inputToken === "object" && inputToken.type === TokenObjectType.functionToken ) { return this.consumeAFunction((inputToken).name); } return inputToken; } /** * 5.4.7. Consume a simple block * https://www.w3.org/TR/css-syntax-3/#consume-a-simple-block */ private consumeASimpleBlock(associatedToken: InputToken): SimpleBlock { const endianToken: "]" | "}" | ")" = { "[": "]", "{": "}", "(": ")", }[associatedToken]; const start = this.nextInputCodePointIndex - 1; const block: SimpleBlock = { type: TokenObjectType.simpleBlock, text: undefined, associatedToken, values: [], }; let nextInputToken; while ((nextInputToken = this.text[this.nextInputCodePointIndex])) { if (nextInputToken === endianToken) { this.nextInputCodePointIndex++; const end = this.nextInputCodePointIndex; block.text = this.text.substring(start, end); return block; } const value = this.consumeAComponentValue(); if (value) { block.values.push(value); } } block.text = this.text.substring(start); return block; } /** * 5.4.8. Consume a function * https://www.w3.org/TR/css-syntax-3/#consume-a-function */ private consumeAFunction(name: string): InputToken { const start = this.nextInputCodePointIndex; const funcToken: FunctionToken = { type: TokenObjectType.function, name, text: undefined, components: [], }; do { if (this.nextInputCodePointIndex >= this.text.length) { funcToken.text = name + "(" + this.text.substring(start); return funcToken; } const nextInputToken = this.text[this.nextInputCodePointIndex]; switch (nextInputToken) { case ")": this.nextInputCodePointIndex++; const end = this.nextInputCodePointIndex; funcToken.text = name + "(" + this.text.substring(start, end); return funcToken; default: const component = this.consumeAComponentValue(); if (component) { funcToken.components.push(component); } // TODO: Else we won't advance } } while (true); } } /** * Consume a CSS3 parsed stylesheet and convert the rules and selectors to the * NativeScript internal JSON representation. */ export class CSSNativeScript { public parseStylesheet(stylesheet: Stylesheet): any { return { type: "stylesheet", stylesheet: { rules: this.parseRules(stylesheet.rules), }, }; } private parseRules(rules: Rule[]): any { return rules.map((rule) => this.parseRule(rule)); } private parseRule(rule: Rule): any { if (rule.type === "at-rule") { return this.parseAtRule(rule); } else if (rule.type === "qualified-rule") { return this.parseQualifiedRule(rule); } } private parseAtRule(rule: AtRule): any { if (rule.name === "import") { // TODO: We have used an "@improt { url('path somewhere'); }" at few places. return { import: rule.prelude .map((m) => (typeof m === "string" ? m : m.text)) .join("") .trim(), type: "import", }; } return; } private parseQualifiedRule(rule: QualifiedRule): any { return { type: "rule", selectors: this.preludeToSelectorsStringArray(rule.prelude), declarations: this.ruleBlockToDeclarations(rule.block.values), }; } private ruleBlockToDeclarations( declarationsInputTokens: InputToken[] ): { type: "declaration"; property: string; value: string }[] { // return declarationsInputTokens; const declarations: { type: "declaration"; property: string; value: string; }[] = []; let property = ""; let value = ""; let reading: "property" | "value" = "property"; for (let i = 0; i < declarationsInputTokens.length; i++) { let inputToken = declarationsInputTokens[i]; if (reading === "property") { if (inputToken === ":") { reading = "value"; } else if (typeof inputToken === "string") { property += inputToken; } else { property += inputToken.text; } } else { if (inputToken === ";") { property = property.trim(); value = value.trim(); declarations.push({ type: "declaration", property, value }); property = ""; value = ""; reading = "property"; } else if (typeof inputToken === "string") { value += inputToken; } else { value += inputToken.text; } } } property = property.trim(); value = value.trim(); if (property || value) { declarations.push({ type: "declaration", property, value }); } return declarations; } private preludeToSelectorsStringArray(prelude: InputToken[]): string[] { let selectors = []; let selector = ""; prelude.forEach((inputToken) => { if (typeof inputToken === "string") { if (inputToken === ",") { if (selector) { selectors.push(selector.trim()); } selector = ""; } else { selector += inputToken; } } else if (typeof inputToken === "object") { selector += inputToken.text; } }); if (selector) { selectors.push(selector.trim()); } return selectors; } }