From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/lib/gradients/parser.ts | 1304 +++++++++++++++++----------------- 1 file changed, 652 insertions(+), 652 deletions(-) (limited to 'apps/web/src/lib/gradients/parser.ts') diff --git a/apps/web/src/lib/gradients/parser.ts b/apps/web/src/lib/gradients/parser.ts index 55440e3c..342780b4 100644 --- a/apps/web/src/lib/gradients/parser.ts +++ b/apps/web/src/lib/gradients/parser.ts @@ -1,652 +1,652 @@ -/* - * 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, -}; +/* + * 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, +}; -- cgit v1.3.1