/* * Original source: https://github.com/rafaelcaricio/gradient-parser/blob/master/lib/parser.js */ type GradientType = | "linear-gradient" | "repeating-linear-gradient" | "radial-gradient" | "repeating-radial-gradient"; type DirectionalOrientation = { type: "directional"; value: string }; type AngularOrientation = { type: "angular"; value: string }; type LinearOrientation = DirectionalOrientation | AngularOrientation; type Distance = | { type: "%"; value: string } | { type: "position-keyword"; value: string } | { type: "calc"; value: string } | { type: "px"; value: string } | { type: "em"; value: string }; type PositionValue = { x?: Distance; y?: Distance }; type Position = { type: "position"; value: PositionValue }; type ExtentKeyword = { type: "extent-keyword"; value: string }; type ShapeValue = "circle" | "ellipse"; type Shape = { type: "shape"; value: ShapeValue; style?: Distance | ExtentKeyword | Position; at?: Position; }; type DefaultRadial = { type: "default-radial"; at: Position }; type RadialOrientation = | Shape | (ExtentKeyword & { at?: Position }) | DefaultRadial; export type GradientOrientation = LinearOrientation | Array; export type Color = | { type: "hex"; value: string } | { type: "literal"; value: string } | { type: "rgb"; value: Array } | { type: "rgba"; value: Array } | { type: "hsl"; value: [string, string, string] } | { type: "hsla"; value: [string, string, string, string] } | { type: "var"; value: string }; export type ColorStop = Color & { length?: Distance }; export type GradientAst = { type: GradientType; orientation: GradientOrientation | undefined; colorStops: Array; }; type Tokens = { linearGradient: RegExp; repeatingLinearGradient: RegExp; radialGradient: RegExp; repeatingRadialGradient: RegExp; sideOrCorner: RegExp; extentKeywords: RegExp; positionKeywords: RegExp; pixelValue: RegExp; percentageValue: RegExp; emValue: RegExp; angleValue: RegExp; radianValue: RegExp; startCall: RegExp; endCall: RegExp; comma: RegExp; hexColor: RegExp; literalColor: RegExp; rgbColor: RegExp; rgbaColor: RegExp; varColor: RegExp; calcValue: RegExp; variableName: RegExp; number: RegExp; hslColor: RegExp; hslaColor: RegExp; }; const tokens: Tokens = { linearGradient: /^(-(webkit|o|ms|moz)-)?(linear-gradient)/i, repeatingLinearGradient: /^(-(webkit|o|ms|moz)-)?(repeating-linear-gradient)/i, radialGradient: /^(-(webkit|o|ms|moz)-)?(radial-gradient)/i, repeatingRadialGradient: /^(-(webkit|o|ms|moz)-)?(repeating-radial-gradient)/i, sideOrCorner: /^to (left (top|bottom)|right (top|bottom)|top (left|right)|bottom (left|right)|left|right|top|bottom)/i, extentKeywords: /^(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)/, positionKeywords: /^(left|center|right|top|bottom)/i, pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/, percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))%/, emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/, angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/, radianValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))rad/, startCall: /^\(/, endCall: /^\)/, comma: /^,/, hexColor: /^#([0-9a-fA-F]+)/, literalColor: /^([a-zA-Z]+)/, rgbColor: /^rgb/i, rgbaColor: /^rgba/i, varColor: /^var/i, calcValue: /^calc/i, variableName: /^(--[a-zA-Z0-9-,\s#]+)/, number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/, hslColor: /^hsl/i, hslaColor: /^hsla/i, }; let input = ""; const error = ({ message }: { message: string }): never => { const err = new Error(`${input}: ${message}`); (err as Error & { source?: string }).source = input; throw err; }; const getAst = (): Array => { const ast = matchListDefinitions(); if (input.length > 0) { error({ message: "Invalid input not EOF" }); } return ast; }; const matchListDefinitions = (): Array => matchListing({ matcher: matchDefinition }); const matchDefinition = (): GradientAst | undefined => matchGradient({ gradientType: "linear-gradient", pattern: tokens.linearGradient, orientationMatcher: matchLinearOrientation, }) || matchGradient({ gradientType: "repeating-linear-gradient", pattern: tokens.repeatingLinearGradient, orientationMatcher: matchLinearOrientation, }) || matchGradient({ gradientType: "radial-gradient", pattern: tokens.radialGradient, orientationMatcher: matchListRadialOrientations, }) || matchGradient({ gradientType: "repeating-radial-gradient", pattern: tokens.repeatingRadialGradient, orientationMatcher: matchListRadialOrientations, }); const matchGradient = ({ gradientType, pattern, orientationMatcher, }: { gradientType: GradientType; pattern: RegExp; orientationMatcher: () => GradientOrientation | undefined; }): GradientAst | undefined => matchCall({ pattern, callback: () => { const orientation = orientationMatcher(); if (orientation && !scan({ regexp: tokens.comma })) { error({ message: "Missing comma before color stops" }); } return { type: gradientType, orientation, colorStops: matchListing({ matcher: matchColorStop }), }; }, }); const matchCall = ({ pattern, callback, }: { pattern: RegExp; callback: (captures: RegExpExecArray) => T; }): T | undefined => { const captures = scan({ regexp: pattern }); if (!captures) { return undefined; } if (!scan({ regexp: tokens.startCall })) { error({ message: "Missing (" }); } const result = callback(captures); if (!scan({ regexp: tokens.endCall })) { error({ message: "Missing )" }); } return result; }; const matchLinearOrientation = (): LinearOrientation | undefined => { const sideOrCorner = matchSideOrCorner(); if (sideOrCorner) { return sideOrCorner; } const legacyDirection = match({ type: "position-keyword", pattern: tokens.positionKeywords, captureIndex: 1, }); if (legacyDirection) { return { type: "directional", value: legacyDirection.value, }; } return matchAngle(); }; const matchSideOrCorner = (): DirectionalOrientation | undefined => match({ type: "directional", pattern: tokens.sideOrCorner, captureIndex: 1 }); const matchAngle = (): AngularOrientation | undefined => match({ type: "angular", pattern: tokens.angleValue, captureIndex: 1 }) || match({ type: "angular", pattern: tokens.radianValue, captureIndex: 1 }); const matchListRadialOrientations = (): | Array | undefined => { const radialOrientation = matchRadialOrientation(); if (!radialOrientation) { return undefined; } const radialOrientations: Array = [radialOrientation]; const lookaheadCache = input; if (!scan({ regexp: tokens.comma })) { return radialOrientations; } const nextRadial = matchRadialOrientation(); if (!nextRadial) { input = lookaheadCache; return radialOrientations; } radialOrientations.push(nextRadial); return radialOrientations; }; const matchRadialOrientation = (): RadialOrientation | undefined => { const radialType = matchCircle() || matchEllipse(); if (radialType) { radialType.at = matchAtPosition(); return radialType; } const extent = matchExtentKeyword(); if (extent) { const positionAt = matchAtPosition(); if (positionAt) { return { ...extent, at: positionAt }; } return extent; } const implicitEllipse = matchImplicitEllipse(); if (implicitEllipse) { return implicitEllipse; } const atPosition = matchAtPosition(); if (atPosition) { return { type: "default-radial", at: atPosition }; } const defaultPosition = matchPositioning(); if (defaultPosition) { return { type: "default-radial", at: defaultPosition }; } return undefined; }; const matchImplicitEllipse = (): Shape | undefined => { const lookaheadCache = input; const width = matchDistance(); if (!width) { return undefined; } const height = matchDistance(); if (!height) { input = lookaheadCache; return undefined; } const atPos = matchAtPosition(); if (!atPos) { input = lookaheadCache; return undefined; } return { type: "shape", value: "ellipse", style: { type: "position", value: { x: width, y: height } }, at: atPos, }; }; const matchCircle = (): Shape | undefined => { const circle = match({ type: "shape", pattern: /^(circle)/i, captureIndex: 0, }) as Shape | undefined; if (!circle) { return undefined; } circle.style = matchLength() || matchExtentKeyword(); circle.value = "circle"; return circle; }; const matchEllipse = (): Shape | undefined => { const ellipse = match({ type: "shape", pattern: /^(ellipse)/i, captureIndex: 0, }) as Shape | undefined; if (!ellipse) { return undefined; } ellipse.style = matchPositioning() || matchDistance() || matchExtentKeyword(); ellipse.value = "ellipse"; return ellipse; }; const matchExtentKeyword = (): ExtentKeyword | undefined => match({ type: "extent-keyword", pattern: tokens.extentKeywords, captureIndex: 1, }); const matchAtPosition = (): Position | undefined => { if (!match({ type: "position", pattern: /^at/, captureIndex: 0 })) { return undefined; } const positioning = matchPositioning(); if (!positioning) { error({ message: "Missing positioning value" }); } return positioning; }; const matchPositioning = (): Position | undefined => { const location = matchCoordinates(); if (!location.x && !location.y) { return undefined; } return { type: "position", value: location, }; }; const matchCoordinates = (): PositionValue => ({ x: matchDistance(), y: matchDistance(), }); const matchListing = ({ matcher, }: { matcher: () => T | undefined; }): Array => { const captures = matcher(); const result: Array = []; if (!captures) { return result; } result.push(captures); while (scan({ regexp: tokens.comma })) { const nextCapture = matcher() ?? error({ message: "One extra comma" }); result.push(nextCapture); } return result; }; const matchColorStop = (): ColorStop => { const color = matchColor() ?? error({ message: "Expected color definition" }); const length = matchDistance(); return { ...color, length }; }; const matchColor = (): Color | undefined => matchHexColor() || matchHSLAColor() || matchHSLColor() || matchRGBAColor() || matchRGBColor() || matchVarColor() || matchLiteralColor(); const matchLiteralColor = (): Color | undefined => match({ type: "literal", pattern: tokens.literalColor, captureIndex: 0 }); const matchHexColor = (): Color | undefined => match({ type: "hex", pattern: tokens.hexColor, captureIndex: 1 }); const matchRGBColor = (): Color | undefined => matchCall({ pattern: tokens.rgbColor, callback: () => ({ type: "rgb", value: matchListing({ matcher: matchNumber }), }), }); const matchRGBAColor = (): Color | undefined => matchCall({ pattern: tokens.rgbaColor, callback: () => ({ type: "rgba", value: matchListing({ matcher: matchNumber }), }), }); const matchVarColor = (): Color | undefined => matchCall({ pattern: tokens.varColor, callback: () => ({ type: "var", value: matchVariableName(), }), }); const matchHSLColor = (): Color | undefined => matchCall({ pattern: tokens.hslColor, callback: () => { const lookahead = scan({ regexp: tokens.percentageValue }); if (lookahead) { error({ message: "HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage", }); } const hue = matchNumber(); scan({ regexp: tokens.comma }); let captures = scan({ regexp: tokens.percentageValue }); const sat = captures ? captures[1] : null; scan({ regexp: tokens.comma }); captures = scan({ regexp: tokens.percentageValue }); const light = captures ? captures[1] : null; const ensuredSat = sat ?? error({ message: "Expected percentage value for saturation and lightness in HSL", }); const ensuredLight = light ?? error({ message: "Expected percentage value for saturation and lightness in HSL", }); return { type: "hsl", value: [hue, ensuredSat, ensuredLight], }; }, }); const matchHSLAColor = (): Color | undefined => matchCall({ pattern: tokens.hslaColor, callback: () => { const hue = matchNumber(); scan({ regexp: tokens.comma }); let captures = scan({ regexp: tokens.percentageValue }); const sat = captures ? captures[1] : null; scan({ regexp: tokens.comma }); captures = scan({ regexp: tokens.percentageValue }); const light = captures ? captures[1] : null; scan({ regexp: tokens.comma }); const alpha = matchNumber(); const ensuredSat = sat ?? error({ message: "Expected percentage value for saturation and lightness in HSLA", }); const ensuredLight = light ?? error({ message: "Expected percentage value for saturation and lightness in HSLA", }); return { type: "hsla", value: [hue, ensuredSat, ensuredLight, alpha], }; }, }); const matchVariableName = (): string => { const captures = scan({ regexp: tokens.variableName }) ?? error({ message: "Expected CSS variable name" }); return captures[1]; }; const matchNumber = (): string => { const captures = scan({ regexp: tokens.number }) ?? error({ message: "Expected number" }); return captures[1]; }; const matchDistance = (): Distance | undefined => match({ type: "%", pattern: tokens.percentageValue, captureIndex: 1 }) || matchPositionKeyword() || matchCalc() || matchLength(); const matchPositionKeyword = (): Distance | undefined => match({ type: "position-keyword", pattern: tokens.positionKeywords, captureIndex: 1, }); const matchCalc = (): Distance | undefined => matchCall({ pattern: tokens.calcValue, callback: () => { let openParenCount = 1; let index = 0; while (openParenCount > 0 && index < input.length) { const char = input.charAt(index); if (char === "(") { openParenCount++; } else if (char === ")") { openParenCount--; } index++; } if (openParenCount > 0) { error({ message: "Missing closing parenthesis in calc() expression" }); } const calcContent = input.slice(0, index - 1); consume({ size: index - 1 }); return { type: "calc", value: calcContent, }; }, }); const matchLength = (): Distance | undefined => match({ type: "px", pattern: tokens.pixelValue, captureIndex: 1 }) || match({ type: "em", pattern: tokens.emValue, captureIndex: 1 }); const match = ({ type, pattern, captureIndex, }: { type: TType; pattern: RegExp; captureIndex: number; }): { type: TType; value: string } | undefined => { const captures = scan({ regexp: pattern }); if (!captures) { return undefined; } return { type, value: captures[captureIndex], }; }; const scan = ({ regexp }: { regexp: RegExp }): RegExpExecArray | null => { const blankCaptures = /^[\n\r\t\s]+/.exec(input); if (blankCaptures) { consume({ size: blankCaptures[0].length }); } const captures = regexp.exec(input); if (captures) { consume({ size: captures[0].length }); } return captures; }; const consume = ({ size }: { size: number }): void => { input = input.slice(size); }; export const parseGradient = ({ code, }: { code: string; }): Array => { input = code.toString().trim(); if (input.endsWith(";")) { input = input.slice(0, -1); } return getAst(); }; export const GradientParser = { parse: parseGradient, };