oklchtohex 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,8 +34,41 @@ const convertedTailwindCss = convertTailwindCssToHex(css);
34
34
 
35
35
  - Known Tailwind default variables like `--color-red-500` are replaced with exact Tailwind HEX palette values.
36
36
  - Unknown variables and raw `oklch(...)` values use functional conversion.
37
+ - Tailwind opacity utilities like `border-red-500/30` are handled when emitted as `color-mix(... var(--color-*) ..., transparent)` and converted to 8-digit HEX when the source variable is resolvable.
37
38
  - `gamut` option supports `clip` (default) and `fit`.
38
39
 
40
+ ## Vite plugin (auto on dev + build)
41
+
42
+ Use the built-in plugin to auto-convert CSS during `vite dev` and `vite build`.
43
+
44
+ ```js
45
+ import { defineConfig } from "vite";
46
+ import react from "@vitejs/plugin-react";
47
+ import { oklchToHexVitePlugin } from "oklchtohex/vite";
48
+
49
+ export default defineConfig({
50
+ plugins: [
51
+ react(),
52
+ oklchToHexVitePlugin({
53
+ gamut: "clip",
54
+ convertDev: true,
55
+ convertBuild: true
56
+ })
57
+ ]
58
+ });
59
+ ```
60
+
61
+ ### Plugin options
62
+
63
+ - `gamut`: `"clip"` (default) or `"fit"`
64
+ - `includeAlpha`: `"auto"` (default), `"always"`, `"never"`
65
+ - `uppercase`: `false` (default)
66
+ - `onError`: `"preserve"` (default) or `"throw"`
67
+ - `include`: `RegExp | (id) => boolean` (default matches CSS-like files)
68
+ - `exclude`: `RegExp | (id) => boolean`
69
+ - `convertDev`: `true` by default
70
+ - `convertBuild`: `true` by default
71
+
39
72
  ## Tailwind v4 build integration (package-only)
40
73
 
41
74
  Create a small Node script in your app, for example `scripts/convert-tailwind-to-hex.mjs`:
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "oklchtohex",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Convert OKLCH colors to HEX as a JavaScript package.",
5
5
  "type": "module",
6
- "main": "./src/converter.js",
6
+ "main": "./src/index.js",
7
7
  "exports": {
8
- ".": "./src/converter.js"
8
+ ".": "./src/index.js",
9
+ "./vite": "./src/vite-plugin.js",
10
+ "./converter": "./src/converter.js"
9
11
  },
10
12
  "files": [
11
13
  "src",
@@ -16,6 +18,8 @@
16
18
  "hex",
17
19
  "tailwind",
18
20
  "tailwindcss",
21
+ "vite",
22
+ "vite-plugin",
19
23
  "color",
20
24
  "converter"
21
25
  ],
package/src/converter.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { getTailwindDefaultHexForVar } from "./tailwind-default-hex.js";
2
2
  const OKLCH_REGEX = /oklch\(([^)]*)\)/gi;
3
+ const COLOR_MIX_WITH_TRANSPARENT_REGEX =
4
+ /color-mix\(\s*in\s+oklab\s*,\s*var\(\s*(--[\w-]+)\s*\)\s*([0-9.]+)%\s*,\s*transparent\s*\)/gi;
3
5
 
4
6
  function clamp(value, min, max) {
5
7
  return Math.min(max, Math.max(min, value));
@@ -157,6 +159,45 @@ function channelToHex(channel) {
157
159
  return byte.toString(16).padStart(2, "0");
158
160
  }
159
161
 
162
+ function normalizeHexColor(hex) {
163
+ const trimmed = hex.trim().toLowerCase();
164
+ if (!trimmed.startsWith("#")) {
165
+ return null;
166
+ }
167
+ const raw = trimmed.slice(1);
168
+ if (raw.length === 3) {
169
+ return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`;
170
+ }
171
+ if (raw.length === 4) {
172
+ return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`;
173
+ }
174
+ if (raw.length === 6 || raw.length === 8) {
175
+ return `#${raw}`;
176
+ }
177
+ return null;
178
+ }
179
+
180
+ function splitHexChannels(hex) {
181
+ const normalized = normalizeHexColor(hex);
182
+ if (!normalized) {
183
+ return null;
184
+ }
185
+ const raw = normalized.slice(1);
186
+ const rgbHex = raw.slice(0, 6);
187
+ const alphaHex = raw.length === 8 ? raw.slice(6, 8) : "ff";
188
+ return { rgbHex, alphaHex };
189
+ }
190
+
191
+ function alphaToHex(alpha) {
192
+ return channelToHex(clamp(alpha, 0, 1));
193
+ }
194
+
195
+ function combineAlpha(existingAlphaHex, mixPercent) {
196
+ const baseAlpha = Number.parseInt(existingAlphaHex, 16) / 255;
197
+ const mixedAlpha = baseAlpha * clamp(mixPercent / 100, 0, 1);
198
+ return alphaToHex(mixedAlpha);
199
+ }
200
+
160
201
  function normalizeInput(input) {
161
202
  if (typeof input === "string") {
162
203
  return parseOklch(input);
@@ -219,14 +260,63 @@ function replaceTailwindDefaultColorVariables(text) {
219
260
  );
220
261
  }
221
262
 
263
+ function collectHexVariableDeclarations(text) {
264
+ const declarationRegex = /(--[\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;/g;
265
+ const map = new Map();
266
+ let match = declarationRegex.exec(text);
267
+ while (match) {
268
+ const variableName = match[1].toLowerCase();
269
+ const normalized = normalizeHexColor(match[2]);
270
+ if (normalized) {
271
+ map.set(variableName, normalized);
272
+ }
273
+ match = declarationRegex.exec(text);
274
+ }
275
+ return map;
276
+ }
277
+
278
+ function replaceTailwindColorMixWithHex(text, variableHexMap) {
279
+ return text.replace(
280
+ COLOR_MIX_WITH_TRANSPARENT_REGEX,
281
+ (fullMatch, variableNameRaw, percentRaw) => {
282
+ const variableName = String(variableNameRaw).toLowerCase();
283
+ const percent = Number.parseFloat(percentRaw);
284
+ if (Number.isNaN(percent)) {
285
+ return fullMatch;
286
+ }
287
+
288
+ const sourceHex =
289
+ variableHexMap.get(variableName) ??
290
+ getTailwindDefaultHexForVar(variableName);
291
+
292
+ if (!sourceHex) {
293
+ return fullMatch;
294
+ }
295
+
296
+ const channels = splitHexChannels(sourceHex);
297
+ if (!channels) {
298
+ return fullMatch;
299
+ }
300
+
301
+ const alphaHex = combineAlpha(channels.alphaHex, percent);
302
+ return `#${channels.rgbHex}${alphaHex}`;
303
+ }
304
+ );
305
+ }
306
+
222
307
  export function replaceOklchInText(text, options = {}) {
223
308
  if (typeof text !== "string") {
224
309
  throw new Error("replaceOklchInText expects a string");
225
310
  }
226
311
  const { onError = "preserve", ...convertOptions } = options;
227
312
  const withMappedTailwindDefaults = replaceTailwindDefaultColorVariables(text);
313
+ const variableHexMap = collectHexVariableDeclarations(withMappedTailwindDefaults);
314
+ const withResolvedTailwindColorMix = replaceTailwindColorMixWithHex(
315
+ withMappedTailwindDefaults,
316
+ variableHexMap
317
+ );
228
318
 
229
- return withMappedTailwindDefaults.replace(OKLCH_REGEX, (match) => {
319
+ return withResolvedTailwindColorMix.replace(OKLCH_REGEX, (match) => {
230
320
  try {
231
321
  return oklchToHex(match, convertOptions);
232
322
  } catch (error) {
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./converter.js";
2
+ export { oklchToHexVitePlugin } from "./vite-plugin.js";
3
+
@@ -0,0 +1,105 @@
1
+ import { replaceOklchInText } from "./converter.js";
2
+
3
+ const DEFAULT_CSS_FILE_REGEX =
4
+ /\.(css|pcss|postcss|scss|sass|less|styl|stylus)(?:$|\?)/i;
5
+
6
+ function isRegexMatch(regex, value) {
7
+ if (!(regex instanceof RegExp)) {
8
+ return false;
9
+ }
10
+ regex.lastIndex = 0;
11
+ return regex.test(value);
12
+ }
13
+
14
+ function matches(matcher, value) {
15
+ if (!matcher) {
16
+ return true;
17
+ }
18
+ if (matcher instanceof RegExp) {
19
+ return isRegexMatch(matcher, value);
20
+ }
21
+ if (typeof matcher === "function") {
22
+ return Boolean(matcher(value));
23
+ }
24
+ return false;
25
+ }
26
+
27
+ function shouldProcessId(id, include, exclude) {
28
+ if (!matches(include, id)) {
29
+ return false;
30
+ }
31
+ if (exclude && matches(exclude, id)) {
32
+ return false;
33
+ }
34
+ return true;
35
+ }
36
+
37
+ function maybeConvertCss(code, convertOptions) {
38
+ if (typeof code !== "string") {
39
+ return null;
40
+ }
41
+ if (!code.toLowerCase().includes("oklch(")) {
42
+ return null;
43
+ }
44
+ const converted = replaceOklchInText(code, convertOptions);
45
+ return converted === code ? null : converted;
46
+ }
47
+
48
+ export function oklchToHexVitePlugin(options = {}) {
49
+ const {
50
+ include = DEFAULT_CSS_FILE_REGEX,
51
+ exclude,
52
+ convertDev = true,
53
+ convertBuild = true,
54
+ ...convertOptions
55
+ } = options;
56
+
57
+ let command = "build";
58
+
59
+ return {
60
+ name: "oklchtohex-vite-plugin",
61
+ enforce: "post",
62
+ configResolved(config) {
63
+ command = config.command;
64
+ },
65
+ transform(code, id) {
66
+ if (command === "serve" && !convertDev) {
67
+ return null;
68
+ }
69
+ if (command === "build" && !convertBuild) {
70
+ return null;
71
+ }
72
+ if (!shouldProcessId(id, include, exclude)) {
73
+ return null;
74
+ }
75
+
76
+ const converted = maybeConvertCss(code, convertOptions);
77
+ if (!converted) {
78
+ return null;
79
+ }
80
+ return { code: converted, map: null };
81
+ },
82
+ generateBundle(_, bundle) {
83
+ if (!convertBuild) {
84
+ return;
85
+ }
86
+ for (const [fileName, asset] of Object.entries(bundle)) {
87
+ if (asset.type !== "asset") {
88
+ continue;
89
+ }
90
+ if (!shouldProcessId(fileName, include, exclude)) {
91
+ continue;
92
+ }
93
+ if (typeof asset.source !== "string") {
94
+ continue;
95
+ }
96
+
97
+ const converted = maybeConvertCss(asset.source, convertOptions);
98
+ if (converted) {
99
+ asset.source = converted;
100
+ }
101
+ }
102
+ }
103
+ };
104
+ }
105
+