summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/gradients
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/gradients')
-rw-r--r--apps/web/src/lib/gradients/parser.ts1304
1 files changed, 652 insertions, 652 deletions
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<RadialOrientation>;
-
-export type Color =
- | { type: "hex"; value: string }
- | { type: "literal"; value: string }
- | { type: "rgb"; value: Array<string> }
- | { type: "rgba"; value: Array<string> }
- | { 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<ColorStop>;
-};
-
-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<GradientAst> => {
- const ast = matchListDefinitions();
-
- if (input.length > 0) {
- error({ message: "Invalid input not EOF" });
- }
-
- return ast;
-};
-
-const matchListDefinitions = (): Array<GradientAst> =>
- 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 = <T>({
- 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<RadialOrientation>
- | undefined => {
- const radialOrientation = matchRadialOrientation();
- if (!radialOrientation) {
- return undefined;
- }
-
- const radialOrientations: Array<RadialOrientation> = [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 = <T>({
- matcher,
-}: {
- matcher: () => T | undefined;
-}): Array<T> => {
- const captures = matcher();
- const result: Array<T> = [];
-
- 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 = <TType extends string>({
- 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<GradientAst> => {
- 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<RadialOrientation>;
+
+export type Color =
+ | { type: "hex"; value: string }
+ | { type: "literal"; value: string }
+ | { type: "rgb"; value: Array<string> }
+ | { type: "rgba"; value: Array<string> }
+ | { 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<ColorStop>;
+};
+
+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<GradientAst> => {
+ const ast = matchListDefinitions();
+
+ if (input.length > 0) {
+ error({ message: "Invalid input not EOF" });
+ }
+
+ return ast;
+};
+
+const matchListDefinitions = (): Array<GradientAst> =>
+ 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 = <T>({
+ 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<RadialOrientation>
+ | undefined => {
+ const radialOrientation = matchRadialOrientation();
+ if (!radialOrientation) {
+ return undefined;
+ }
+
+ const radialOrientations: Array<RadialOrientation> = [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 = <T>({
+ matcher,
+}: {
+ matcher: () => T | undefined;
+}): Array<T> => {
+ const captures = matcher();
+ const result: Array<T> = [];
+
+ 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 = <TType extends string>({
+ 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<GradientAst> => {
+ input = code.toString().trim();
+ if (input.endsWith(";")) {
+ input = input.slice(0, -1);
+ }
+ return getAst();
+};
+
+export const GradientParser = {
+ parse: parseGradient,
+};