everyhexcode 0.0.0-stage → 0.1.0
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/LICENSE.md +135 -0
- package/README.md +99 -2
- package/action.yml +91 -0
- package/dist/everyhexcode.js +5397 -0
- package/package.json +40 -4
- package/src/cli.js +160 -0
- package/src/engine/apca.js +10 -0
- package/src/engine/check-input.js +540 -0
- package/src/engine/check.js +777 -0
- package/src/engine/color.js +364 -0
- package/src/engine/parse.js +208 -0
- package/src/engine/plain.js +124 -0
- package/src/files.js +97 -0
- package/src/no-apca.js +10 -0
- package/src/output.js +110 -0
- package/src/render.js +329 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
// The colour checker's reader: whatever people paste (a list of colours, CSS with custom properties and rules, SCSS or
|
|
2
|
+
// Less variables, a Tailwind config, design tokens in W3C DTCG, Style Dictionary or a Figma variables export) becomes one
|
|
3
|
+
// list of named colour tokens, each with its light or dark mode and where its value sits in the text, so a fix can be
|
|
4
|
+
// written back into the same text. CSS rules that set both a text and a background colour are kept as declared pairs.
|
|
5
|
+
import { parseColor } from './parse.js';
|
|
6
|
+
import { formatHex, interpolate, parse as cparse } from 'culori/fn';
|
|
7
|
+
|
|
8
|
+
export const MAX_TOKENS = 300;
|
|
9
|
+
|
|
10
|
+
/** A token: { name, hex, alpha, mode, raw, span:[start,end]|null, format, alias? }. */
|
|
11
|
+
const CSS_NAMES = new Set(('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue '
|
|
12
|
+
+ 'chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki '
|
|
13
|
+
+ 'darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise '
|
|
14
|
+
+ 'darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod '
|
|
15
|
+
+ 'gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue '
|
|
16
|
+
+ 'lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray '
|
|
17
|
+
+ 'lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple '
|
|
18
|
+
+ 'mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite '
|
|
19
|
+
+ 'navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink '
|
|
20
|
+
+ 'plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue '
|
|
21
|
+
+ 'slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow '
|
|
22
|
+
+ 'yellowgreen').split(' '));
|
|
23
|
+
const FN = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(/i;
|
|
24
|
+
const BARE_HSL = /^-?[\d.]+(?:deg)?\s+[\d.]+%\s+[\d.]+%(?:\s*\/\s*[\d.]+%?)?$/;
|
|
25
|
+
|
|
26
|
+
/** Whether a declared value is meant as a colour (so --radius: 0.5rem or --z: 10 are never read as one). */
|
|
27
|
+
export function looksColor(raw) {
|
|
28
|
+
const v = String(raw).trim();
|
|
29
|
+
if (!v || v.length > 120) return false;
|
|
30
|
+
if (/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v)) return true;
|
|
31
|
+
if (FN.test(v)) return true;
|
|
32
|
+
if (BARE_HSL.test(v)) return true;
|
|
33
|
+
return CSS_NAMES.has(v.toLowerCase()) || v.toLowerCase() === 'transparent';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The way a value is written, so a fix is written back the same way. */
|
|
37
|
+
export function formatOf(raw) {
|
|
38
|
+
const v = String(raw).trim().toLowerCase();
|
|
39
|
+
if (v.startsWith('#')) return v.length <= 5 || v.length === 7 ? (/[A-F]/.test(raw) && !/[a-f]/.test(raw) ? 'HEX' : 'hex') : 'hex8';
|
|
40
|
+
if (BARE_HSL.test(v)) return 'hsl-bare';
|
|
41
|
+
const m = v.match(/^([a-z]+)\(/);
|
|
42
|
+
if (m) return m[1].replace(/a$/, '') + (/,/.test(v) ? '-legacy' : '');
|
|
43
|
+
return 'name';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** color-mix(in <space>, a p%, b q%) worked out as browsers do it (CSS Color 5), or null. */
|
|
47
|
+
function mix(v) {
|
|
48
|
+
const m = /^color-mix\(\s*in\s+([\w-]+)(?:\s+(?:shorter|longer|increasing|decreasing)\s+hue)?\s*,(.*)\)$/i.exec(v.trim());
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const parts = []; let depth = 0, cur = '';
|
|
51
|
+
for (const ch of m[2]) { if (ch === '(') depth++; if (ch === ')') depth--; if (ch === ',' && !depth) { parts.push(cur); cur = ''; } else cur += ch; }
|
|
52
|
+
parts.push(cur);
|
|
53
|
+
if (parts.length !== 2) return null;
|
|
54
|
+
const one = (x) => { const pm = x.trim().match(/^(.*?)(?:\s+([\d.]+)%)?$/s); return { c: parseColor(pm[1].trim()), p: pm[2] != null ? Number(pm[2]) : null }; };
|
|
55
|
+
const a = one(parts[0]), b = one(parts[1]);
|
|
56
|
+
if (!a.c.hex || !b.c.hex) return null;
|
|
57
|
+
let pa = a.p, pb = b.p;
|
|
58
|
+
if (pa == null && pb == null) { pa = 50; pb = 50; } else if (pa == null) pa = 100 - pb; else if (pb == null) pb = 100 - pa;
|
|
59
|
+
const sum = pa + pb;
|
|
60
|
+
if (sum <= 0) return null;
|
|
61
|
+
const space = { srgb: 'rgb', 'srgb-linear': 'lrgb', oklab: 'oklab', oklch: 'oklch', lab: 'lab', lch: 'lch', hsl: 'hsl', hwb: 'hwb', xyz: 'xyz65', 'xyz-d65': 'xyz65', 'display-p3': 'p3' }[m[1].toLowerCase()] || 'oklab';
|
|
62
|
+
const ca = cparse('#' + a.c.hex), cb = cparse('#' + b.c.hex);
|
|
63
|
+
ca.alpha = a.c.alpha ?? 1; cb.alpha = b.c.alpha ?? 1;
|
|
64
|
+
const out = interpolate([ca, cb], space)(pb / sum);
|
|
65
|
+
return { hex: formatHex(out).slice(1), alpha: Math.round(Math.min(1, out.alpha ?? 1) * (sum < 100 ? sum / 100 : 1) * 1000) / 1000 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const readOne = (raw) => {
|
|
69
|
+
if (/^color-mix\(/i.test(String(raw).trim())) return mix(raw);
|
|
70
|
+
const r = parseColor(String(raw).trim());
|
|
71
|
+
return r.hex ? { hex: r.hex, alpha: r.alpha ?? 1 } : null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Comments out, offsets kept: each comment becomes spaces of the same length. */
|
|
75
|
+
function blankComments(s, line = true) {
|
|
76
|
+
let out = s.replace(/\/\*[^]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
77
|
+
if (line) out = out.replace(/(^|[^:"'(\w\/])\/\/[^\n]*/g, (m, p) => p + ' '.repeat(m.length - p.length));
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DARK_SEL = /\.dark\b|\[data-(?:[\w-]*theme|mode|color-scheme)=["']?dark|\.theme-dark|\bdark-theme\b|\[theme=["']?dark/i;
|
|
82
|
+
const DARK_MEDIA = /prefers-color-scheme\s*:\s*dark/i;
|
|
83
|
+
const LIGHT_SEL = /\.light\b|\[data-(?:theme|mode|color-scheme)=["']?light|prefers-color-scheme\s*:\s*light/i;
|
|
84
|
+
|
|
85
|
+
// where a custom property is a design token: at the top, under :root, html, body, a theme class or attribute, or inside
|
|
86
|
+
// @media, @supports, @layer or @theme around those. One set inside a component (.card { --bg: … }) is that component's.
|
|
87
|
+
const MODE_ATTR = '\\[data-(?:[\\w-]*theme|mode|color-scheme)(?:=["\']?(?:light|dark)["\']?)?\\]';
|
|
88
|
+
const GLOBAL_PART = new RegExp(`^((:root|html|body|:host|\\*)?(${MODE_ATTR}|\\.(dark|light)(-theme|-mode)?|:not\\(\\.dark\\)|:not\\(\\.light\\)|:not\\(${MODE_ATTR}\\))*|:root|html|body|:host|\\*|\\.dark|\\.light)$`, 'i');
|
|
89
|
+
export function isGlobalScope(prelude) {
|
|
90
|
+
const p = String(prelude || '').trim().replace(/:(?:where|is)\(([^()]*)\)/g, '$1');
|
|
91
|
+
if (!p || /^@(media|supports|layer|theme|container|document|scope)\b/i.test(p)) return true;
|
|
92
|
+
if (p.startsWith('@')) return false;
|
|
93
|
+
// a list that includes the root (":root, :root:has(…), [data-theme=light]") applies to the page: it is global
|
|
94
|
+
return p.split(',').some((x) => GLOBAL_PART.test(x.trim().replace(/\s+/g, ' ')) || /^(:root|html)\s+(body)?$/.test(x.trim()));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const PROPS = {
|
|
98
|
+
color: 'fg', 'background-color': 'bg', background: 'bg', 'border-color': 'border', border: 'border', 'border-top': 'border',
|
|
99
|
+
'border-bottom': 'border', outline: 'outline', 'outline-color': 'outline', fill: 'fill', stroke: 'stroke', 'caret-color': 'caret',
|
|
100
|
+
'text-decoration-color': 'decoration', 'column-rule-color': 'border',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** CSS, SCSS and Less: custom properties and variables become tokens; rules keep the colours they set. */
|
|
104
|
+
export function readCss(text) {
|
|
105
|
+
// // comments exist only in SCSS and Less; in plain CSS a // is part of a url(), so only those files lose them
|
|
106
|
+
const src = blankComments(text, /^\s*[$@][\w-]+\s*:/m.test(text) && !/^\s*@(media|supports|import|font-face|layer|container|charset|keyframes)\b/m.test(text.slice(0, 0)));
|
|
107
|
+
const tokens = [], rules = [];
|
|
108
|
+
const stack = []; // preludes of the open blocks
|
|
109
|
+
let i = 0, start = 0;
|
|
110
|
+
// dark when a block says dark: a dark media query, or every selector of the list naming dark outside a :not()
|
|
111
|
+
// (":root:not([data-theme=dark]), [data-theme=light]" is light)
|
|
112
|
+
const darkSel = (p) => { const parts = String(p).split(',').map((x) => x.replace(/:not\((?:[^()]|\([^()]*\))*\)/g, '')); return parts.every((x) => DARK_SEL.test(x)); };
|
|
113
|
+
const mode = () => (stack.some((p) => DARK_MEDIA.test(p) || (!p.startsWith('@') && darkSel(p))) ? 'dark' : 'light');
|
|
114
|
+
const decls = [];
|
|
115
|
+
const flushDecl = (from, to) => {
|
|
116
|
+
const d = src.slice(from, to);
|
|
117
|
+
const m = d.match(/^\s*([$@]?-{0,2}[A-Za-z_][\w-]*)\s*:\s*/);
|
|
118
|
+
if (!m || d.slice(m[0].length).trim() === '') return;
|
|
119
|
+
let vs = from + m[0].length, ve = to;
|
|
120
|
+
while (ve > vs && /\s/.test(src[ve - 1])) ve--;
|
|
121
|
+
let val = src.slice(vs, ve);
|
|
122
|
+
const imp = val.match(/\s*!(?:important|default|global)\s*$/i);
|
|
123
|
+
if (imp) { val = val.slice(0, -imp[0].length); ve -= imp[0].length; }
|
|
124
|
+
if (stack.some((p) => (/^@media\b[^{]*\bprint\b/i.test(p) && !/screen/i.test(p)) || /^@(-webkit-)?keyframes\b/i.test(p))) return; // print styles never reach a screen; keyframe steps are not rules
|
|
125
|
+
decls.push({ supports: stack.some((p) => /^@supports\b/i.test(p)), prop: m[1], value: val, span: [vs, ve], depth: stack.length, prelude: stack[stack.length - 1] || '', global: stack.every(isGlobalScope), mode: mode(), scoped: stack.some((p) => LIGHT_SEL.test(p)), block: blockId });
|
|
126
|
+
};
|
|
127
|
+
let blockId = 0, blockSeq = 0; const blockStack = [];
|
|
128
|
+
let quote = null, paren = 0;
|
|
129
|
+
for (; i < src.length; i++) {
|
|
130
|
+
const ch = src[i];
|
|
131
|
+
if (ch === '\\') { i++; continue; } // an escaped character (Tailwind's .\[\&\>svg\:not\(…) is never a quote, a bracket or a brace
|
|
132
|
+
if (quote) { if (ch === quote) quote = null; continue; }
|
|
133
|
+
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
|
134
|
+
if (ch === '(') { paren++; continue; }
|
|
135
|
+
if (ch === ')') { paren = Math.max(0, paren - 1); continue; }
|
|
136
|
+
if (paren) continue;
|
|
137
|
+
if (ch === '{') {
|
|
138
|
+
const prelude = src.slice(start, i).trim();
|
|
139
|
+
// a declaration ended without ; before a nested block (SCSS) would be lost: only text after the last ; is the prelude
|
|
140
|
+
stack.push(prelude); blockStack.push(blockId); blockId = ++blockSeq; start = i + 1;
|
|
141
|
+
} else if (ch === '}') {
|
|
142
|
+
flushDecl(start, i); stack.pop(); blockId = blockStack.pop() ?? 0; start = i + 1;
|
|
143
|
+
} else if (ch === ';') { flushDecl(start, i); start = i + 1; }
|
|
144
|
+
}
|
|
145
|
+
flushDecl(start, src.length);
|
|
146
|
+
|
|
147
|
+
// variables first: custom properties, $scss and @less
|
|
148
|
+
// a file of themes only (.theme-zinc { … } .theme-rose { … }): its first theme stands in for the default
|
|
149
|
+
let firstTheme = null;
|
|
150
|
+
if (!decls.some((d) => d.global && d.prop.startsWith('--') && looksColor(d.value.trim()))) {
|
|
151
|
+
const d0 = decls.find((d) => d.prop.startsWith('--') && looksColor(d.value.trim()) && /theme|\[data-[\w-]*(theme|mode)|color-scheme/i.test(d.prelude));
|
|
152
|
+
const key = d0 && (d0.prelude.match(/\.[\w-]+|\[data-[\w-]+=["']?[\w-]+["']?\]/) || [])[0];
|
|
153
|
+
if (key) { firstTheme = key; for (const d of decls) if (d.prelude.includes(key)) { d.global = true; if (DARK_SEL.test(d.prelude) && !DARK_SEL.test(key)) d.mode = 'dark'; } }
|
|
154
|
+
}
|
|
155
|
+
const vars = new Map(); // name|mode → token
|
|
156
|
+
const themeScopes = new Set();
|
|
157
|
+
const unread = new Map();
|
|
158
|
+
const scoped = new Map(); // block|name → raw value, for var() inside that component's own rules
|
|
159
|
+
const keep = (t, d) => {
|
|
160
|
+
const k = `${t.name}|${t.mode}`; const was = vars.get(k);
|
|
161
|
+
// a wide-gamut copy inside @supports (color(display-p3 …)) is the same colour for capable screens: the sRGB value stays
|
|
162
|
+
// the one checked and fixed, and the copy is noted so it can be changed too
|
|
163
|
+
if (was && d && d.supports) { was.p3Copy = true; return; }
|
|
164
|
+
if (was) was.dropped = true; vars.set(k, t); tokens.push(t);
|
|
165
|
+
};
|
|
166
|
+
for (const d of decls) {
|
|
167
|
+
if (d.prop.startsWith('--tw-')) continue; // Tailwind's internal variables (ring offsets, gradients stops), not the design's colours
|
|
168
|
+
const isVar = d.prop.startsWith('--') || ((d.prop.startsWith('$') || (d.prop.startsWith('@') && !/^@(media|supports|import|font-face|keyframes|layer|theme|container|tailwind|apply|use|forward|include|mixin|function|return|if|else|each|for|while|charset|namespace|page)$/.test(d.prop))) && d.depth <= 1);
|
|
169
|
+
if (!isVar) continue;
|
|
170
|
+
const raw = d.value.trim();
|
|
171
|
+
if (d.prop.startsWith('--') && !d.global) { scoped.set(`${d.block}|${d.prop}`, raw); if (/theme|\[data-/i.test(d.prelude) && looksColor(raw)) themeScopes.add(d.prelude.replace(/\s+/g, ' ').slice(0, 60)); continue; }
|
|
172
|
+
const ref = raw.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)$/) || raw.match(/^([$@][\w-]+)$/);
|
|
173
|
+
const hslRef = raw.match(/^(hsla?|rgba?|oklch)\(\s*var\(\s*(--[\w-]+)\s*\)\s*(?:\/\s*([\d.]+%?))?\s*\)$/i);
|
|
174
|
+
// light-dark(a, b): one value per mode, each written back where it stands
|
|
175
|
+
const ld = splitLightDark(raw);
|
|
176
|
+
if (ld) {
|
|
177
|
+
for (const [m, part] of [['light', ld[0]], ['dark', ld[1]]]) {
|
|
178
|
+
if (!looksColor(part.text)) continue;
|
|
179
|
+
const c = readOne(part.text);
|
|
180
|
+
if (!c) continue;
|
|
181
|
+
const at = d.span[0] + part.at;
|
|
182
|
+
const tk = { name: d.prop, mode: m, raw: part.text, span: [at, at + part.text.length], format: formatOf(part.text), ...c };
|
|
183
|
+
keep(tk, d);
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const t = { name: d.prop, mode: d.mode, raw, span: d.span, format: formatOf(raw) };
|
|
188
|
+
if (ref) { t.alias = ref[1]; if (ref[2] && looksColor(ref[2])) t.fallback = ref[2].trim(); }
|
|
189
|
+
else if (hslRef) { t.alias = hslRef[2]; t.wrap = hslRef[1].toLowerCase(); t.wrapAlpha = hslRef[3]; t.span = null; }
|
|
190
|
+
else if (looksColor(raw)) {
|
|
191
|
+
const c = readOne(raw);
|
|
192
|
+
if (c) Object.assign(t, c);
|
|
193
|
+
else if (/var\(/.test(raw)) t.mixRaw = raw; // color-mix(… var(--x) …): worked out once the variables are known
|
|
194
|
+
else { unread.set(raw.match(/^([a-z][\w-]*)\(/i)?.[1] || 'color', (unread.get(raw.match(/^([a-z][\w-]*)\(/i)?.[1] || 'color') || 0) + 1); continue; }
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
// a colour made by a function this reader cannot work out (Sass tint-color(), rgba($x, .5), #{…}, color-mix with var())
|
|
198
|
+
const fn = raw.match(/^([a-z][\w-]*)\(/i)?.[1] || (raw.includes('#{') ? '#{…}' : null);
|
|
199
|
+
// color-mix() over variables is worked out after the variables are known (below); anything else is named here
|
|
200
|
+
if (fn && /colou?r|bg|background|text|fg|border|primary|secondary|accent|brand|link|success|danger|warning|info|surface/i.test(d.prop + ' ' + raw) && !/^(calc|var|url|min|max|clamp|env|attr|counter|linear-gradient|radial-gradient|conic-gradient|repeating-linear-gradient)$/i.test(fn)) unread.set(fn, (unread.get(fn) || 0) + 1);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
keep(t, d);
|
|
204
|
+
}
|
|
205
|
+
// a later definition of the same name in the same mode wins, as in the cascade
|
|
206
|
+
for (let i = tokens.length - 1; i >= 0; i--) if (tokens[i].dropped) tokens.splice(i, 1);
|
|
207
|
+
const lookup = (name, m) => vars.get(`${name}|${m}`) || vars.get(`${name}|light`);
|
|
208
|
+
const resolve = (t, seen = new Set()) => {
|
|
209
|
+
if (t.hex || !t.alias) return t;
|
|
210
|
+
if (seen.has(t)) return t;
|
|
211
|
+
seen.add(t);
|
|
212
|
+
const to = lookup(t.alias, t.mode);
|
|
213
|
+
if (!to) return t;
|
|
214
|
+
resolve(to, seen);
|
|
215
|
+
if (t.wrap && to.raw) {
|
|
216
|
+
const c = readOne(`${t.wrap}(${to.raw}${t.wrapAlpha ? ` / ${t.wrapAlpha}` : ''})`);
|
|
217
|
+
if (c) Object.assign(t, c, { via: to.name });
|
|
218
|
+
} else if (to.hex) Object.assign(t, { hex: to.hex, alpha: to.alpha, via: to.name });
|
|
219
|
+
return t;
|
|
220
|
+
};
|
|
221
|
+
tokens.forEach((t) => resolve(t));
|
|
222
|
+
// color-mix() over variables, now that they are known
|
|
223
|
+
for (const t of tokens) if (!t.hex && t.mixRaw) {
|
|
224
|
+
const txt = t.mixRaw.replace(/var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)/g, (m, n) => { const x = vars.get(`${n}|${t.mode}`) || vars.get(`${n}|light`); return x?.hex ? '#' + x.hex + (x.alpha < 1 ? Math.round(x.alpha * 255).toString(16).padStart(2, '0') : '') : m; });
|
|
225
|
+
const c = readOne(txt);
|
|
226
|
+
if (c) Object.assign(t, c, { span: null, via: undefined }); else unread.set('color-mix', (unread.get('color-mix') || 0) + 1);
|
|
227
|
+
}
|
|
228
|
+
// var(--missing, #999): the fallback is what the browser uses
|
|
229
|
+
for (const t of tokens) if (!t.hex && t.fallback) { const c = readOne(t.fallback); if (c) Object.assign(t, c); }
|
|
230
|
+
// var() resolves where it is used: an alias set once (Tailwind v4's @theme --color-background: var(--background))
|
|
231
|
+
// follows its target into dark mode when only the target is redefined there
|
|
232
|
+
if (tokens.some((t) => t.mode === 'dark')) {
|
|
233
|
+
for (const t of tokens.filter((x) => x.mode === 'light' && x.alias && !vars.has(`${x.name}|dark`))) {
|
|
234
|
+
const to = vars.get(`${t.alias}|dark`);
|
|
235
|
+
if (!to) continue;
|
|
236
|
+
resolve(to);
|
|
237
|
+
const c = t.wrap && to.raw ? readOne(`${t.wrap}(${to.raw}${t.wrapAlpha ? ` / ${t.wrapAlpha}` : ''})`) : to.hex ? { hex: to.hex, alpha: to.alpha } : null;
|
|
238
|
+
if (c) { const d = { ...t, ...c, mode: 'dark', span: null, via: to.name }; vars.set(`${t.name}|dark`, d); tokens.push(d); }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// a value in a rule: a colour, a variable, or a colour inside a shorthand
|
|
243
|
+
const valueOf = (raw, m, span, block, depth = 0) => {
|
|
244
|
+
const v = raw.trim();
|
|
245
|
+
if (/gradient\(|url\(/i.test(v)) return { image: true };
|
|
246
|
+
const fn = v.match(/^(hsla?|rgba?|oklch)\(\s*var\(\s*(--[\w-]+)\s*\)\s*(?:\/\s*([\d.]+%?))?\s*\)$/i);
|
|
247
|
+
if (fn) { const t = lookup(fn[2], m); const c = t?.raw && readOne(`${fn[1]}(${t.raw}${fn[3] ? ` / ${fn[3]}` : ''})`); return c ? { ...c, token: t.name } : null; }
|
|
248
|
+
const vr = v.match(/var\(\s*(--[\w-]+)/) || v.match(/^([$@][\w-]+)$/);
|
|
249
|
+
if (vr) {
|
|
250
|
+
// a variable the component sets for itself resolves there first
|
|
251
|
+
const sv = block != null && depth < 6 && scoped.get(`${block}|${vr[1]}`);
|
|
252
|
+
if (sv) { const inner = valueOf(sv, m, null, block, depth + 1); return inner ? { ...inner, token: undefined, span: null, raw: undefined } : null; }
|
|
253
|
+
const t = lookup(vr[1], m); return t?.hex ? { hex: t.hex, alpha: t.alpha, token: t.name } : null;
|
|
254
|
+
}
|
|
255
|
+
const lit = (x, at) => { const c = readOne(x); return c && { ...c, raw: x, format: formatOf(x), span: span ? [span[0] + at, span[0] + at + x.length] : null }; };
|
|
256
|
+
if (looksColor(v)) return lit(v, raw.indexOf(v));
|
|
257
|
+
for (const part of v.split(/\s+(?![^(]*\))/)) if (looksColor(part)) return lit(part, raw.indexOf(part));
|
|
258
|
+
return null;
|
|
259
|
+
};
|
|
260
|
+
const byBlock = new Map();
|
|
261
|
+
for (const d of decls) {
|
|
262
|
+
const kind = PROPS[d.prop.toLowerCase()];
|
|
263
|
+
if (!kind && !/^font-(size|weight)$/i.test(d.prop)) continue;
|
|
264
|
+
if (!d.prelude || d.prop.startsWith('--')) continue;
|
|
265
|
+
let r = byBlock.get(d.block);
|
|
266
|
+
if (!r) { r = { selector: d.prelude.replace(/\s+/g, ' ').slice(0, 120), mode: d.mode, scoped: d.scoped }; byBlock.set(d.block, r); }
|
|
267
|
+
if (/^font-size$/i.test(d.prop)) { const px = toPx(d.value); if (px) r.fontSize = px; continue; }
|
|
268
|
+
if (/^font-weight$/i.test(d.prop)) { const w = d.value.trim() === 'bold' ? 700 : Number(d.value); if (w) r.fontWeight = w; continue; }
|
|
269
|
+
const ldv = splitLightDark(d.value.trim());
|
|
270
|
+
if (ldv) {
|
|
271
|
+
// a rule value that differs by mode: the light one here, the dark one on a twin rule kept for dark mode only
|
|
272
|
+
const off = d.value.indexOf(d.value.trim());
|
|
273
|
+
const lv = valueOf(ldv[0].text, 'light', [d.span[0] + off + ldv[0].at, 0], d.block), dv = valueOf(ldv[1].text, 'dark', [d.span[0] + off + ldv[1].at, 0], d.block);
|
|
274
|
+
if (lv) { r[kind] = lv; r.scoped = true; }
|
|
275
|
+
let twin = byBlock.get(`${d.block}|dark`);
|
|
276
|
+
if (!twin) { twin = { selector: r.selector, mode: 'dark', scoped: true, twinOf: r }; byBlock.set(`${d.block}|dark`, twin); }
|
|
277
|
+
if (dv) twin[kind] = dv;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const v = valueOf(d.value, d.mode, d.span, d.block);
|
|
281
|
+
if (v && (v.hex || v.image)) { r[kind] = v; const tw = byBlock.get(`${d.block}|dark`); if (tw && !tw[kind]) tw[kind] = v; }
|
|
282
|
+
}
|
|
283
|
+
for (const r of byBlock.values()) {
|
|
284
|
+
if (r.twinOf) for (const k of ['fg', 'bg', 'border', 'outline', 'fontSize', 'fontWeight']) if (r[k] === undefined && r.twinOf[k] !== undefined) r[k] = r.twinOf[k];
|
|
285
|
+
if (r.fg || r.bg || r.border || r.outline) rules.push(r);
|
|
286
|
+
}
|
|
287
|
+
const notes = firstTheme ? [`Only the first theme, ${firstTheme}, is checked${themeScopes.size ? `; ${themeScopes.size} other theme ${themeScopes.size === 1 ? 'scope is' : 'scopes are'} not` : ''}. Paste one theme at a time to check another.`] : themeScopes.size ? [`${themeScopes.size} other theme ${themeScopes.size === 1 ? 'scope was' : 'scopes were'} not checked (${[...themeScopes].slice(0, 3).join(', ')}${themeScopes.size > 3 ? ', …' : ''}): only the default light and dark colors are. Paste one theme at a time to check it.`] : [];
|
|
288
|
+
const n = [...unread.values()].reduce((a, b) => a + b, 0);
|
|
289
|
+
if (n) notes.push(`${n} color ${n === 1 ? 'value uses' : 'values use'} a function this checker cannot work out (${[...unread.keys()].slice(0, 5).map((f) => (f.startsWith('#') ? f : `${f}()`)).join(', ')}), so ${n === 1 ? 'it was' : 'they were'} not checked. Paste the compiled CSS to check ${n === 1 ? 'it' : 'them'}.`);
|
|
290
|
+
return { tokens: tokens.filter((t) => t.hex), rules, notes, vars: decls.filter((d) => d.prop.startsWith('--')).length, darkMedia: DARK_MEDIA.test(text) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** light-dark(a, b) → [{ text, at }, { text, at }] (offsets inside the value), or null. */
|
|
294
|
+
export function splitLightDark(v) {
|
|
295
|
+
const m = /^light-dark\(/i.exec(v);
|
|
296
|
+
if (!m || !v.endsWith(')')) return null;
|
|
297
|
+
const inner = v.slice(m[0].length, -1);
|
|
298
|
+
let depth = 0;
|
|
299
|
+
for (let i = 0; i < inner.length; i++) {
|
|
300
|
+
const c = inner[i];
|
|
301
|
+
if (c === '(') depth++; else if (c === ')') depth--;
|
|
302
|
+
else if (c === ',' && !depth) {
|
|
303
|
+
const a = inner.slice(0, i), b = inner.slice(i + 1);
|
|
304
|
+
const at0 = m[0].length + (a.length - a.trimStart().length), at1 = m[0].length + i + 1 + (b.length - b.trimStart().length);
|
|
305
|
+
return [{ text: a.trim(), at: at0 }, { text: b.trim(), at: at1 }];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function toPx(v) {
|
|
312
|
+
const m = String(v).trim().match(/^([\d.]+)(px|rem|em|pt)$/);
|
|
313
|
+
if (!m) return null;
|
|
314
|
+
const n = Number(m[1]);
|
|
315
|
+
return m[2] === 'px' ? n : m[2] === 'pt' ? n * 4 / 3 : n * 16;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** JSON string leaves with their path and where the string sits in the text. */
|
|
319
|
+
function jsonLeaves(text) {
|
|
320
|
+
const out = [];
|
|
321
|
+
let i = 0;
|
|
322
|
+
const ws = () => { while (/\s/.test(text[i] || '')) i++; };
|
|
323
|
+
const str = () => { const s = i; i++; while (i < text.length && text[i] !== '"') { if (text[i] === '\\') i++; i++; } i++; return { value: JSON.parse(text.slice(s, i)), span: [s + 1, i - 1] }; };
|
|
324
|
+
const val = (path) => {
|
|
325
|
+
ws();
|
|
326
|
+
const c = text[i];
|
|
327
|
+
if (c === '{') {
|
|
328
|
+
i++; ws();
|
|
329
|
+
if (text[i] === '}') { i++; return; }
|
|
330
|
+
for (;;) { ws(); const k = str().value; ws(); i++; val([...path, k]); ws(); if (text[i] === ',') { i++; continue; } i++; return; }
|
|
331
|
+
}
|
|
332
|
+
if (c === '[') {
|
|
333
|
+
i++; ws();
|
|
334
|
+
if (text[i] === ']') { i++; return; }
|
|
335
|
+
for (let n = 0; ; n++) { val([...path, String(n)]); ws(); if (text[i] === ',') { i++; continue; } i++; return; }
|
|
336
|
+
}
|
|
337
|
+
if (c === '"') { const s = str(); out.push({ path, value: s.value, span: s.span }); return; }
|
|
338
|
+
const m = text.slice(i).match(/^(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/);
|
|
339
|
+
i += m ? m[0].length : 1;
|
|
340
|
+
};
|
|
341
|
+
val([]);
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const MODE_WORD = /^(light|dark|day|night|lightmode|darkmode|light-mode|dark-mode)$/i;
|
|
346
|
+
const modeOfWord = (w) => (/dark|night/i.test(w) ? 'dark' : 'light');
|
|
347
|
+
|
|
348
|
+
/** Design tokens: DTCG ($value), Style Dictionary (value), Figma variables, Tokens Studio, or any JSON of colours. */
|
|
349
|
+
/** JSON5 and JSON with comments (Primer, Tokens Studio): comments, unquoted keys, single quotes and trailing commas made plain JSON. */
|
|
350
|
+
export function json5(text) {
|
|
351
|
+
let out = '', i = 0;
|
|
352
|
+
while (i < text.length) {
|
|
353
|
+
const c = text[i];
|
|
354
|
+
if (c === '"' || c === "'") { let j = i + 1, str = ''; while (j < text.length && text[j] !== c) { if (text[j] === '\\') { str += text[j] + text[j + 1]; j += 2; continue; } str += text[j] === '"' ? '\\"' : text[j]; j++; } out += '"' + str + '"'; i = j + 1; continue; }
|
|
355
|
+
if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; continue; }
|
|
356
|
+
if (c === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 2; continue; }
|
|
357
|
+
if (/[A-Za-z_$]/.test(c) && /[{,]\s*$/.test(out)) { const m = /^[A-Za-z_$][\w$-]*/.exec(text.slice(i)); const after = text.slice(i + m[0].length).match(/^\s*:/); if (after) { out += '"' + m[0] + '"'; i += m[0].length; continue; } }
|
|
358
|
+
out += c; i++;
|
|
359
|
+
}
|
|
360
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function readJson(text) {
|
|
364
|
+
let data, converted = false;
|
|
365
|
+
try { data = JSON.parse(text); } catch {
|
|
366
|
+
try { const plain = json5(text); data = JSON.parse(plain); text = plain; converted = true; } catch { return null; }
|
|
367
|
+
}
|
|
368
|
+
const tokens = [];
|
|
369
|
+
// Figma variables: the REST API's meta.variables, or a plugin export with variables[] and valuesByMode
|
|
370
|
+
const figma = data?.meta?.variables ? Object.values(data.meta.variables) : Array.isArray(data?.variables) ? data.variables : null;
|
|
371
|
+
if (figma && figma.some((v) => v && v.valuesByMode)) {
|
|
372
|
+
const modes = new Map();
|
|
373
|
+
for (const col of Object.values(data?.meta?.variableCollections || {})) for (const m of col.modes || []) modes.set(m.modeId, m.name);
|
|
374
|
+
for (const m of data.modes ? Object.entries(data.modes) : []) modes.set(m[0], m[1]?.name || m[1]);
|
|
375
|
+
const byId = new Map(figma.map((v) => [v.id, v]));
|
|
376
|
+
const rgbOf = (v, modeId, depth = 0) => {
|
|
377
|
+
const x = v.valuesByMode?.[modeId] ?? Object.values(v.valuesByMode || {})[0];
|
|
378
|
+
if (x && x.type === 'VARIABLE_ALIAS' && depth < 8) { const to = byId.get(x.id); return to ? rgbOf(to, modeId in (to.valuesByMode || {}) ? modeId : Object.keys(to.valuesByMode)[0], depth + 1) : null; }
|
|
379
|
+
return x && typeof x.r === 'number' ? x : null;
|
|
380
|
+
};
|
|
381
|
+
for (const v of figma) {
|
|
382
|
+
if (v.resolvedType && v.resolvedType !== 'COLOR') continue;
|
|
383
|
+
for (const modeId of Object.keys(v.valuesByMode || {})) {
|
|
384
|
+
const x = rgbOf(v, modeId);
|
|
385
|
+
if (!x) continue;
|
|
386
|
+
const c = readOne(`rgb(${Math.round(x.r * 255)} ${Math.round(x.g * 255)} ${Math.round(x.b * 255)})`);
|
|
387
|
+
const mname = String(modes.get(modeId) || '');
|
|
388
|
+
tokens.push({ name: String(v.name || v.id).replace(/\//g, '.'), ...c, alpha: x.a ?? 1, mode: /dark|night/i.test(mname) ? 'dark' : 'light', raw: null, span: null, format: 'hex' });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return { kind: 'figma', tokens };
|
|
392
|
+
}
|
|
393
|
+
const leaves = jsonLeaves(text);
|
|
394
|
+
const dtcg = leaves.some((l) => l.path[l.path.length - 1] === '$value') || /"\$value"\s*:\s*\{/.test(text);
|
|
395
|
+
const sd = !dtcg && leaves.some((l) => l.path[l.path.length - 1] === 'value');
|
|
396
|
+
const byName = new Map();
|
|
397
|
+
for (const l of leaves) {
|
|
398
|
+
let path = l.path;
|
|
399
|
+
const last = path[path.length - 1];
|
|
400
|
+
if (dtcg) { if (last !== '$value') continue; path = path.slice(0, -1); }
|
|
401
|
+
else if (sd) { if (last !== 'value') continue; path = path.slice(0, -1); }
|
|
402
|
+
const modeIdx = path.findIndex((p) => MODE_WORD.test(p));
|
|
403
|
+
const mode = modeIdx >= 0 ? modeOfWord(path[modeIdx]) : 'light';
|
|
404
|
+
const clean = path.filter((p, k) => k !== modeIdx && !/^\$/.test(p));
|
|
405
|
+
const name = clean.join('.');
|
|
406
|
+
const raw = String(l.value).trim();
|
|
407
|
+
const t = { name, mode, raw, span: l.span, format: formatOf(raw) };
|
|
408
|
+
const alias = raw.match(/^\{([^}]+)\}$/);
|
|
409
|
+
if (alias) t.alias = alias[1];
|
|
410
|
+
else if (looksColor(raw)) { const c = readOne(raw); if (!c) continue; Object.assign(t, c); }
|
|
411
|
+
else continue;
|
|
412
|
+
tokens.push(t);
|
|
413
|
+
byName.set(`${name}|${mode}`, t);
|
|
414
|
+
}
|
|
415
|
+
// DTCG 2025: $value as an object { colorSpace, components, alpha, hex }
|
|
416
|
+
if (dtcg) {
|
|
417
|
+
const walk = (o, path) => {
|
|
418
|
+
if (!o || typeof o !== 'object') return;
|
|
419
|
+
if (o.$value && typeof o.$value === 'object' && (o.$value.hex || o.$value.components)) {
|
|
420
|
+
const v = o.$value;
|
|
421
|
+
const sp = String(v.colorSpace || 'srgb').toLowerCase(), k = (v.components || []).map((x) => (x === 'none' ? 0 : Number(x)));
|
|
422
|
+
const css = ['oklch', 'oklab', 'lab', 'lch', 'hwb'].includes(sp) ? `${sp}(${k.join(' ')})` : sp === 'hsl' ? `hsl(${k[0]} ${k[1]}% ${k[2]}%)` : `color(${sp} ${k.join(' ')})`;
|
|
423
|
+
const c = v.hex ? readOne(v.hex) : readOne(css);
|
|
424
|
+
const modeIdx = path.findIndex((p) => MODE_WORD.test(p));
|
|
425
|
+
if (c) { const tk = { name: path.filter((_, k) => k !== modeIdx && !/^\$/.test(path[k])).join('.'), ...c, alpha: v.alpha ?? 1, mode: modeIdx >= 0 ? modeOfWord(path[modeIdx]) : 'light', raw: null, span: null, format: 'hex' }; tokens.push(tk); byName.set(`${tk.name}|${tk.mode}`, tk); }
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
for (const [k, x] of Object.entries(o)) if (!k.startsWith('$')) walk(x, [...path, k]);
|
|
429
|
+
};
|
|
430
|
+
walk(data, []);
|
|
431
|
+
}
|
|
432
|
+
const byLower = new Map([...byName.entries()].map(([k, v]) => [k.toLowerCase(), v]));
|
|
433
|
+
for (const t of tokens) {
|
|
434
|
+
let x = t, n = 0;
|
|
435
|
+
// references are matched as written, then without case and a leading @ ({@color_primitives.Yellow.200}, Figma's SDS)
|
|
436
|
+
const find = (a, m) => byName.get(`${a}|${m}`) || byLower.get(`${a.replace(/^@/, '').toLowerCase()}|${m}`);
|
|
437
|
+
while (x && !x.hex && x.alias && n++ < 8) x = find(x.alias, t.mode) || find(x.alias, 'light');
|
|
438
|
+
if (x?.hex && x !== t) Object.assign(t, { hex: x.hex, alpha: x.alpha, via: x.name });
|
|
439
|
+
}
|
|
440
|
+
const unresolved = tokens.filter((t) => !t.hex && t.alias).length;
|
|
441
|
+
// JSON5 was read through a plain-JSON copy, so positions do not match the file: fixes are listed, not written
|
|
442
|
+
if (converted) for (const t of tokens) t.span = null;
|
|
443
|
+
return { kind: dtcg ? 'dtcg' : sd ? 'style-dictionary' : 'json', tokens: tokens.filter((t) => t.hex), vars: unresolved, notes: unresolved ? [`${unresolved} ${unresolved === 1 ? 'token points' : 'tokens point'} to a color defined in another file, so ${unresolved === 1 ? 'it was' : 'they were'} not checked: check the token files together.`] : [] };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A Tailwind config (JS object literal): every string colour under `colors`, named by its path (blue-500, primary). */
|
|
447
|
+
export function readTailwind(text) {
|
|
448
|
+
const src = blankComments(text);
|
|
449
|
+
const tokens = [];
|
|
450
|
+
const path = [];
|
|
451
|
+
const re = /(?:(['"`]?)([A-Za-z_$][\w$-]*|\d+)\1\s*:\s*(?:(\{)|(['"`])([^'"`\n]*)\4))|(\})/g;
|
|
452
|
+
let m;
|
|
453
|
+
while ((m = re.exec(src))) {
|
|
454
|
+
if (m[6]) { path.pop(); continue; }
|
|
455
|
+
const key = m[2];
|
|
456
|
+
if (m[3]) { path.push(key); continue; }
|
|
457
|
+
const raw = m[5];
|
|
458
|
+
const inColors = path.includes('colors') || path.includes('backgroundColor') || path.includes('textColor');
|
|
459
|
+
if (!inColors || !looksColor(raw)) continue;
|
|
460
|
+
const c = readOne(raw);
|
|
461
|
+
if (!c) continue;
|
|
462
|
+
const at = m.index + m[0].length - raw.length - 1;
|
|
463
|
+
const p = [...path.slice(path.lastIndexOf('colors') + 1), key].filter((x) => x !== 'DEFAULT');
|
|
464
|
+
tokens.push({ name: p.join('-'), ...c, mode: 'light', raw, span: [at, at + raw.length], format: formatOf(raw) });
|
|
465
|
+
}
|
|
466
|
+
return { tokens };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const ANY_COLOR = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)]*\)/gi;
|
|
470
|
+
|
|
471
|
+
/** A list: colours anywhere in the text, each with the name written before it on its line ("primary: #2563eb"). */
|
|
472
|
+
export function readList(text) {
|
|
473
|
+
const tokens = [];
|
|
474
|
+
const seen = new Map();
|
|
475
|
+
const add = (raw, start, name) => {
|
|
476
|
+
const c = readOne(raw);
|
|
477
|
+
if (!c) return;
|
|
478
|
+
const n = (name || '').trim().replace(/[:=\s]+$/, '').replace(/^[-*•\s]+/, '').slice(0, 60) || `#${c.hex}`;
|
|
479
|
+
if (seen.has(n)) { if (seen.get(n) === c.hex) return; }
|
|
480
|
+
seen.set(n, c.hex);
|
|
481
|
+
tokens.push({ name: n, ...c, mode: 'light', raw, span: [start, start + raw.length], format: formatOf(raw), unnamed: !name });
|
|
482
|
+
};
|
|
483
|
+
let m, found = 0;
|
|
484
|
+
ANY_COLOR.lastIndex = 0;
|
|
485
|
+
while ((m = ANY_COLOR.exec(text))) {
|
|
486
|
+
found++;
|
|
487
|
+
const lineStart = text.lastIndexOf('\n', m.index) + 1;
|
|
488
|
+
const before = text.slice(lineStart, m.index);
|
|
489
|
+
const nm = before.match(/([A-Za-z][\w .\-/]{0,50}?)\s*[:=]\s*$/);
|
|
490
|
+
add(m[0], m.index, nm ? nm[1] : null);
|
|
491
|
+
}
|
|
492
|
+
if (found) {
|
|
493
|
+
// CSS colour names written beside functional colours ("rgb(0 0 0 / 50%), white")
|
|
494
|
+
const taken = tokens.map((t) => t.span);
|
|
495
|
+
const re = /(?<![\w#(-])([a-z]+)(?![\w(-])/gi;
|
|
496
|
+
while ((m = re.exec(text))) if (CSS_NAMES.has(m[1].toLowerCase()) && !taken.some(([a, b]) => m.index >= a && m.index < b) && !/[:=]\s*$/.test(text.slice(text.lastIndexOf('\n', m.index) + 1, m.index)) && !/[:=]/.test(text.slice(m.index + m[1].length, text.indexOf('\n', m.index) < 0 ? undefined : text.indexOf('\n', m.index)))) add(m[1], m.index, null);
|
|
497
|
+
}
|
|
498
|
+
if (!found) {
|
|
499
|
+
// bare words: "ff5733 00adcc", "red, navy", "ff5733-00adcc"
|
|
500
|
+
const re = /[^\s,;|]+/g;
|
|
501
|
+
while ((m = re.exec(text))) {
|
|
502
|
+
const parts = /^[0-9a-f]{6}(?:-[0-9a-f]{6})+$/i.test(m[0]) ? m[0].split('-') : [m[0]];
|
|
503
|
+
let off = m.index;
|
|
504
|
+
for (const p of parts) { if (/^[0-9a-f]{3,8}$/i.test(p) || CSS_NAMES.has(p.toLowerCase())) add(p, off, null); off += p.length + 1; }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return { tokens };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Read anything pasted into the checker. The answer: { kind, tokens, rules, warnings }. Tokens with the same name in
|
|
512
|
+
* the light and the dark mode both stay, each with its mode.
|
|
513
|
+
*/
|
|
514
|
+
export function readInput(text, max = 200000) {
|
|
515
|
+
const s = String(text || '').slice(0, max);
|
|
516
|
+
const t = s.trim();
|
|
517
|
+
let out;
|
|
518
|
+
if (/^[{[]/.test(t)) {
|
|
519
|
+
const j = readJson(t);
|
|
520
|
+
// text that starts like JSON but is not (even as JSON5) is an error to report, not a list of colours to guess at
|
|
521
|
+
if (!j && /^\s*\{[\s\S]*"\s*:/.test(t)) return { kind: 'json', tokens: [], rules: [], warnings: ['This looks like JSON but could not be read (check for missing quotes or commas): no colors were checked.'] };
|
|
522
|
+
if (j) out = { kind: j.kind, tokens: j.tokens.map((x) => ({ ...x, span: x.span ? [x.span[0] + s.indexOf(t), x.span[1] + s.indexOf(t)] : null })), rules: [], notes: j.notes, vars: j.vars };
|
|
523
|
+
}
|
|
524
|
+
if (!out && /(module\.exports|export\s+default|\bcolors\s*:\s*\{)/.test(s) && !/--[\w-]+\s*:/.test(s)) {
|
|
525
|
+
out = { kind: 'tailwind', ...readTailwind(s), rules: [] };
|
|
526
|
+
}
|
|
527
|
+
if (!out && /[{;]/.test(s) && /(--[\w-]+|\$[\w-]+|@[\w-]+|\b(?:color|background(?:-color)?))\s*:/.test(s)) {
|
|
528
|
+
const c = readCss(s);
|
|
529
|
+
out = { kind: /@theme\b/.test(s) ? 'tailwind4' : /\$[\w-]+\s*:/.test(s) ? 'scss' : /@[\w-]+\s*:/.test(s) && !/--[\w-]+\s*:/.test(s) ? 'less' : 'css', ...c };
|
|
530
|
+
}
|
|
531
|
+
if (!out || (!out.tokens.length && !(out.rules || []).length && !out.vars)) out = { kind: 'list', ...readList(s), rules: [] };
|
|
532
|
+
const warnings = [...(out.notes || [])];
|
|
533
|
+
if (out.tokens.length > MAX_TOKENS) {
|
|
534
|
+
// keep the named roles (background, text, primary…) before the steps of colour scales (red-50 … red-950)
|
|
535
|
+
const step = (t) => (/(^|[-_.])(background|bg|foreground|fg|text|surface|border|primary|accent|muted|card|ring|input|link|destructive|error|success|warning)([-_.]|$)/i.test(t.name) ? 0 : /[-_.](50|[1-9]00|950|[1-9]|1[0-2])$/.test(t.name) ? 2 : 1);
|
|
536
|
+
out.tokens = out.tokens.map((t, i) => [t, i]).sort((a, b) => step(a[0]) - step(b[0]) || a[1] - b[1]).slice(0, MAX_TOKENS).sort((a, b) => a[1] - b[1]).map((x) => x[0]);
|
|
537
|
+
warnings.push(`Only ${MAX_TOKENS} of the colors are checked: the named ones first, then scale steps.`);
|
|
538
|
+
}
|
|
539
|
+
return { ...out, warnings };
|
|
540
|
+
}
|