From d07665c2a26d1a5ad3bdd56ea16bbadd0d81da65 Mon Sep 17 00:00:00 2001 From: Danry Ague <74456102+decanTyme@users.noreply.github.com> Date: Sat, 21 Jan 2023 06:29:59 +0800 Subject: [PATCH] fix(color-validator): improve handling of non-strings --- .gitignore | 4 ++ package.json | 4 +- src/color.ts | 69 ++++++++++++----------------- src/plugin.ts | 29 ++++++------ src/types.ts | 4 ++ src/utils.ts | 40 +++++++++++++---- test/utils.spec.ts | 107 +++++++++++++++++++++++++++++++-------------- 7 files changed, 155 insertions(+), 102 deletions(-) diff --git a/.gitignore b/.gitignore index 5e4da0c..cf14cca 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,8 @@ yarn-*.log # Parcel Artifacts .parcel-cache +# Build Artifacts dist + +# Jest Artifacts +coverage diff --git a/package.json b/package.json index 8f40528..1a9b8ea 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "dist" ], "scripts": { - "prepare": "yarn test", "prepublishOnly": "cross-env NODE_ENV=production yarn build", "watch": "parcel watch", - "dev": "concurrently \"parcel watch\" \"yarn test:dev\"", + "dev": "concurrently \"yarn watch\" \"yarn test:dev\"", + "prebuild": "yarn test", "build": "parcel build", "test": "jest", "test:dev": "jest --watch", diff --git a/src/color.ts b/src/color.ts index c4c425c..e13bcd9 100644 --- a/src/color.ts +++ b/src/color.ts @@ -2,24 +2,23 @@ // Copyright (c) Tailwind Labs, Inc. (https://tailwindcss.com/) // and used under the terms of the MIT license -import namedColors from "color-name" +import Colors from "color-name" import { TailwindColorGroup, TailwindThemeColors, } from "tailwindcss/tailwind-config" -import { Color, Maybe } from "./types" +import invariant from "tiny-invariant" +import { Color } from "./types" const HEX = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i const SHORT_HEX = /^#([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i const VALUE = `(?:\\d+|\\d*\\.\\d+)%?` const SEP = `(?:\\s*,\\s*|\\s+)` const ALPHA_SEP = `\\s*[,/]\\s*` + const RGB = new RegExp( `^rgba?\\(\\s*(${VALUE})${SEP}(${VALUE})${SEP}(${VALUE})(?:${ALPHA_SEP}(${VALUE}))?\\s*\\)$` ) -const HSL = new RegExp( - `^hsla?\\(\\s*((?:${VALUE})(?:deg|rad|grad|turn)?)${SEP}(${VALUE})${SEP}(${VALUE})(?:${ALPHA_SEP}(${VALUE}))?\\s*\\)$` -) export const flattenColorPalette = ( colors: TailwindThemeColors @@ -35,38 +34,17 @@ export const flattenColorPalette = ( ) ) -export function parseColor(value: string): Maybe { - if (!value) return null - +export function parseColor(value: string): Color { value = value.trim() + if (value === "transparent") { return { mode: "rgb", color: ["0", "0", "0"], alpha: "0" } } - if (value in namedColors) { - return { - mode: "rgb", - color: namedColors[value as keyof typeof namedColors].map((v) => - v.toString() - ), - } - } - - const hex = value - .replace(SHORT_HEX, (_, r, g, b, a) => - ["#", r, r, g, g, b, b, a ? a + a : ""].join("") - ) - .match(HEX) - - if (hex) { + if (value in Colors) { return { mode: "rgb", - color: [ - parseInt(hex[1], 16), - parseInt(hex[2], 16), - parseInt(hex[3], 16), - ].map((v) => v.toString()), - alpha: hex[4] ? (parseInt(hex[4], 16) / 255).toString() : undefined, + color: Colors[value as keyof typeof Colors].map((v) => v.toString()), } } @@ -80,20 +58,27 @@ export function parseColor(value: string): Maybe { } } - const hslMatch = value.match(HSL) + // We already filter out and validate before even trying + // to parse, so this should now always match a hex + const hex = value + .replace(SHORT_HEX, (_, r, g, b, a) => + ["#", r, r, g, g, b, b, a ? a + a : ""].join("") + ) + .match(HEX) + + invariant(hex, `Invalid value: ${value}`) - if (hslMatch) { - return { - mode: "hsl", - color: [hslMatch[1], hslMatch[2], hslMatch[3]].map((v) => v.toString()), - alpha: hslMatch[4]?.toString?.(), - } + return { + mode: "rgb", + color: [ + parseInt(hex[1], 16), + parseInt(hex[2], 16), + parseInt(hex[3], 16), + ].map((v) => v.toString()), + alpha: hex[4] ? (parseInt(hex[4], 16) / 255).toString() : undefined, } - - return null } -export function formatColor({ mode, color, alpha }: Color) { - if (!color) return null - return `${mode}(${color?.join(" ")}${alpha ? ` / ${alpha}` : ""})` +export function formatColor({ mode, color, alpha }: Color): string { + return `${mode}(${color.join(" ")}${alpha ? ` / ${alpha}` : ""})` } diff --git a/src/plugin.ts b/src/plugin.ts index df80401..3b4b1c0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,13 +1,14 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import type { Chart, Plugin } from "chart.js" -import set from "lodash.set" import get from "lodash.get" +import set from "lodash.set" import resolveConfig from "tailwindcss/resolveConfig" import { TailwindConfig } from "tailwindcss/tailwind-config" import invariant from "tiny-invariant" + import { flattenColorPalette, formatColor, parseColor } from "./color" -import { Maybe, MaybeArray, ParsableOptions } from "./types" -import { hasAlpha, twColorValidator } from "./utils" +import { MaybeArray, ParsableOptions } from "./types" +import { hasValidAlpha, isValidArray, twColorValidator } from "./utils" const twColorsPlugin = ( tailwindConfig: TailwindConfig, @@ -25,30 +26,25 @@ const twColorsPlugin = ( const parseTailwindColor = ( value: MaybeArray - ): Maybe> => { - if (!value) return null + ): MaybeArray => { + invariant(value, `Invalid value: ${value}`) - if (Array.isArray(value)) + if (isValidArray(value)) { return value.map((_val) => parseTailwindColor(_val)) + } - if (hasAlpha(value)) { + if (hasValidAlpha(value)) { const [color, alpha] = value.split("/") const parsedColor = parseColor(parseTailwindColor(color)) - if (!parsedColor) return null - return formatColor({ ...parsedColor, alpha: parseInt(alpha, 10) / 100, }) } - const parsedColor = parseColor(colorPalette[value] ?? value) - - if (!parsedColor) return null - - return formatColor(parsedColor) + return formatColor(parseColor(colorPalette[value] ?? value)) } const plugin = (chart: Chart) => { @@ -64,7 +60,6 @@ const twColorsPlugin = ( "pointHoverBorderColor", "fill.above", "fill.below", - "fill", ] parsableOpts.forEach((parsableOpt) => { @@ -76,7 +71,9 @@ const twColorsPlugin = ( get(dataset, parsableOpt) || (isValidTwColor(chartOpt) ? chartOpt : defaultOpt) - if (color) set(dataset, parsableOpt, parseTailwindColor(color)) + if (isValidTwColor(color, { strict: false })) { + set(dataset, parsableOpt, parseTailwindColor(color)) + } }) }) } diff --git a/src/types.ts b/src/types.ts index 3524632..4ecd22d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,3 +18,7 @@ export type Color = { color: Array alpha?: string | number } + +export interface TwColorValidatorOpts { + strict?: boolean +} diff --git a/src/utils.ts b/src/utils.ts index 3069eb7..cc6efcb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,22 +1,46 @@ import { TailwindColorGroup } from "tailwindcss/tailwind-config" +import { TwColorValidatorOpts } from "./types" -const VALID_COLOR_FORM = `((#[0-9A-Fa-f]{6})|(?!#)[a-z]+(-?[0-9]{2,3}|))` +const VALID_HEX = `#([A-Fa-f\\d]{6})([A-Fa-f\\d]{2})?(?!\\/(?!\\S))` +const VALID_COLOR_FORM = `${VALID_HEX}|(? => + Array.isArray(value) && value.every((v) => typeof v === "string") + /** - * Checks if a given color is valid according to the - * specified TailwindCSS config. + * Checks if a given color/value is valid, and whether it should to be parsed. * - * @returns A validator function + * @returns A validator function. */ export const twColorValidator = (colorPalette: TailwindColorGroup) => - (value: string): boolean => - Object.hasOwn(colorPalette, value) + (value: unknown, { strict = true }: TwColorValidatorOpts = {}): boolean => { + if (!value) return false + + // Can be any valid value, not just what's in the config + if (!strict) { + // Since some colors are stored in arrays, assuming the values + // in the array are valid, the array itself is valid + if (isValidArray(value)) return true + + if ( + typeof value !== "string" || + /\b(?:rgba?)\b|\b(?:hsla?)\b/gi.test(value) + ) { + return false + } + + return new RegExp(`${VALID_COLOR_FORM}(${VALID_ALPHA})?`).test(value) + } + + // Strictly from the specified config + return typeof value === "string" && Object.hasOwn(colorPalette, value) + } /** * Checks first if the color is in a valid form, then * checks if it has a valid `alpha` color channel. */ -export const hasAlpha = (value: string): boolean => - new RegExp(`(?<=(${VALID_COLOR_FORM}))${VALID_ALPHA}`, "g").test(value) +export const hasValidAlpha = (value: string): boolean => + new RegExp(`(?<=(${VALID_COLOR_FORM}))${VALID_ALPHA}`).test(value) diff --git a/test/utils.spec.ts b/test/utils.spec.ts index d912ab0..6045a48 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -2,7 +2,7 @@ import resolveConfig from "tailwindcss/resolveConfig" import invariant from "tiny-invariant" import { flattenColorPalette } from "../src/color" -import { hasAlpha, twColorValidator } from "../src/utils" +import { hasValidAlpha, twColorValidator } from "../src/utils" // @ts-ignore import tailwindConfig from "./tailwind.config" @@ -16,118 +16,157 @@ invariant(colors, "TailwindCSS theme colors is undefined!") const colorPalette = flattenColorPalette(colors) const isValidTwColor = twColorValidator(colorPalette) -describe("Validator is working", () => { +describe("Validator is working with config colors only (strict)", () => { test("If `transparent` is valid", () => { - expect(isValidTwColor("transparent")).toEqual(true) + expect(isValidTwColor("transparent")).toBe(true) }) test("If `black` is valid", () => { - expect(isValidTwColor("black")).toEqual(true) + expect(isValidTwColor("black")).toBe(true) }) test("If `yellow-50` is valid", () => { - expect(isValidTwColor("yellow-50")).toEqual(true) + expect(isValidTwColor("yellow-50")).toBe(true) }) test("If `red-100` is valid", () => { - expect(isValidTwColor("red-100")).toEqual(true) - }) - - test("If `green-900` is valid", () => { - expect(isValidTwColor("green-900")).toEqual(true) + expect(isValidTwColor("red-100")).toBe(true) }) test("If `` is invalid", () => { - expect(isValidTwColor("")).toEqual(false) + expect(isValidTwColor("")).toBe(false) }) test("If `orange-90` is invalid", () => { - expect(isValidTwColor("orange-90")).toEqual(false) + expect(isValidTwColor("orange-90")).toBe(false) }) test("If `pink-250` is invalid", () => { - expect(isValidTwColor("pink-250")).toEqual(false) + expect(isValidTwColor("pink-250")).toBe(false) }) test("If `indigo-0` is invalid", () => { - expect(isValidTwColor("indigo-0")).toEqual(false) + expect(isValidTwColor("indigo-0")).toBe(false) }) test("If `#c08240` is invalid", () => { - expect(isValidTwColor("#c08240")).toEqual(false) + expect(isValidTwColor("#c08240")).toBe(false) }) +}) + +describe("Validator is working (non-strict)", () => { + const opts = { strict: false } + + test("If arrays with valid values should be parsed", () => { + expect(isValidTwColor(["red-600", "#3b82f6/75"], opts)).toBe(true) + }) + + test("If only valid values should be parsed", () => { + expect(isValidTwColor("orange", opts)).toBe(true) + expect(isValidTwColor("orange/50", opts)).toBe(true) + expect(isValidTwColor("stone-50", opts)).toBe(true) + expect(isValidTwColor("stone-50/30", opts)).toBe(true) + expect(isValidTwColor("green-900", opts)).toBe(true) + expect(isValidTwColor("green-900/55", opts)).toBe(true) + expect(isValidTwColor("#c08240", opts)).toBe(true) + expect(isValidTwColor("#3b82f6/75", opts)).toBe(true) - test("If `b69576` is invalid", () => { - expect(isValidTwColor("b69576")).toEqual(false) + expect(isValidTwColor("emerald-", opts)).toBe(false) + expect(isValidTwColor("zinc-0", opts)).toBe(false) + expect(isValidTwColor("#cyan-900", opts)).toBe(false) + expect(isValidTwColor("#cyan-900/55", opts)).toBe(false) + expect(isValidTwColor("b69576", opts)).toBe(false) + expect(isValidTwColor("#b69576/", opts)).toBe(false) + expect(isValidTwColor("/")).toBe(false) + expect(isValidTwColor("/0")).toBe(false) + expect(isValidTwColor("#/20")).toBe(false) + }) + + test("If `rgb/a` form should not be parsed", () => { + expect(isValidTwColor("rgb(0,0,0)", opts)).toBe(false) + expect(isValidTwColor("rgb(0 0 0)", opts)).toBe(false) + expect(isValidTwColor("rgba(0,0,0,0.1)", opts)).toBe(false) + expect(isValidTwColor("rgb(0 0 0 / 0.1)", opts)).toBe(false) + }) + + test("If `hsl/a` form should not be parsed", () => { + expect(isValidTwColor("hsl(50,80%,40%)", opts)).toBe(false) + expect(isValidTwColor("hsl(150deg 30% 60%)", opts)).toBe(false) + expect(isValidTwColor("hsla(0.3turn,60%,45%,.7)", opts)).toBe(false) + expect(isValidTwColor("hsla(0 80% 50% / 25%)", opts)).toBe(false) }) }) describe("Validator is working with extended colors", () => { test("If `main` is valid", () => { - expect(isValidTwColor("main")).toEqual(true) + expect(isValidTwColor("main")).toBe(true) }) test("If `choco-50` is valid", () => { - expect(isValidTwColor("choco-50")).toEqual(true) + expect(isValidTwColor("choco-50")).toBe(true) }) test("If `mango-200` is invalid", () => { - expect(isValidTwColor("mango-200")).toEqual(false) + expect(isValidTwColor("mango-200")).toBe(false) }) }) describe("Alpha validator is working", () => { test("If `yellow-50/1` is valid", () => { - expect(hasAlpha("yellow-50/1")).toEqual(true) + expect(hasValidAlpha("yellow-50/1")).toBe(true) }) test("If `red-100/100` is valid", () => { - expect(hasAlpha("red-100/100")).toEqual(true) + expect(hasValidAlpha("red-100/100")).toBe(true) }) test("If `green-900/50` is valid", () => { - expect(hasAlpha("green-900/50")).toEqual(true) + expect(hasValidAlpha("green-900/50")).toBe(true) }) test("If `lime-600/200` is invalid", () => { - expect(hasAlpha("green-600/200")).toEqual(false) + expect(hasValidAlpha("green-600/200")).toBe(false) }) test("If `orange-200/` is invalid", () => { - expect(hasAlpha("orange-200/")).toEqual(false) + expect(hasValidAlpha("orange-200/")).toBe(false) }) test("If `pink-300/0` is invalid", () => { - expect(hasAlpha("pink-300/0")).toEqual(false) + expect(hasValidAlpha("pink-300/0")).toBe(false) }) test("If `/` is invalid", () => { - expect(hasAlpha("/")).toEqual(false) + expect(hasValidAlpha("/")).toBe(false) }) test("If `/0` is invalid", () => { - expect(hasAlpha("/0")).toEqual(false) + expect(hasValidAlpha("/0")).toBe(false) + }) + + test("If `#/20` is invalid", () => { + expect(hasValidAlpha("#/20")).toBe(false) }) }) -describe("Alpha validator is working with arbitrary values", () => { +describe("Alpha validator is working with hex values", () => { test("If `#c08240/20` is valid", () => { - expect(hasAlpha("#c08240/20")).toEqual(true) + expect(hasValidAlpha("#c08240/20")).toBe(true) }) test("If `#a42c20/200` is invalid", () => { - expect(hasAlpha("#a42c20/200")).toEqual(false) + expect(hasValidAlpha("#a42c20/200")).toBe(false) }) test("If `#a42c20/0` is invalid", () => { - expect(hasAlpha("#a42c20/200")).toEqual(false) + expect(hasValidAlpha("#a42c20/200")).toBe(false) }) test("If `b69576/45` is invalid", () => { - expect(hasAlpha("b69576/45")).toEqual(false) + expect(hasValidAlpha("b69576/45")).toBe(false) }) test("If `#b69576/` is invalid", () => { - expect(hasAlpha("#b69576/")).toEqual(false) + expect(hasValidAlpha("#b69576/")).toBe(false) }) })