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 +33 -0
- package/package.json +7 -3
- package/src/converter.js +91 -1
- package/src/index.js +3 -0
- package/src/vite-plugin.js +105 -0
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.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Convert OKLCH colors to HEX as a JavaScript package.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./src/
|
|
6
|
+
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/
|
|
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
|
|
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,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
|
+
|