Skip to content

Commit a57db93

Browse files
wa0x6eChaituVR
andauthored
feat: add script to import skins (#490)
* feat: add script to import v1 skins color * feat: add skins table to sql schema * feat: infer content color * refactor: remove unused function * feat: add theme variable to skins --------- Co-authored-by: Chaitanya <[email protected]>
1 parent 7128781 commit a57db93

File tree

2 files changed

+322
-0
lines changed

2 files changed

+322
-0
lines changed

scripts/import-skins.ts

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
/**
2+
* This script will import all skins from the snapshot-spaces repository
3+
* (https://github.com/snapshot-labs/snapshot-spaces/tree/master/skins)
4+
* into the hub database
5+
*
6+
* Only color variables will be imported, and each space will be associated to its
7+
* own skin (1-1 relationship).
8+
* Only skins for spaces with custom domain will be imported
9+
*
10+
* All imported colors will be in 6-character hex format, and will not support transparency.
11+
* All colors with transparency will be opacified, based on the background color
12+
*
13+
* To run this script: yarn ts-node scripts/import-skins.ts
14+
*/
15+
16+
import 'dotenv/config';
17+
import kebabCase from 'lodash/kebabCase';
18+
import fetch from 'node-fetch';
19+
import db from '../src/helpers/mysql';
20+
21+
const SKINS_ROOT_PATH =
22+
'https://raw.githubusercontent.com/snapshot-labs/snapshot-spaces/refs/heads/master/skins/';
23+
24+
const COLOR_MAP = {
25+
white: 'ffffff',
26+
black: '000000',
27+
red: 'ff0000',
28+
green: '00ff00',
29+
blue: '0000ff',
30+
yellow: 'ffff00',
31+
lightgrey: 'd3d3d3',
32+
orange: 'ffa500',
33+
darkgrey: 'a9a9a9',
34+
darkgray: 'a9a9a9',
35+
darkgoldenrod: 'b8860b'
36+
};
37+
38+
const SKIN_COLORS = ['primary', 'bg', 'text', 'link', 'border', 'header', 'heading'];
39+
40+
const skins = {};
41+
42+
/**
43+
* Get brightness from a RGB color
44+
*
45+
* @param r
46+
* @param g
47+
* @param b
48+
* @returns brightness value between 0 (black) and 1 (white)
49+
*/
50+
function getBrightness(r: number, g: number, b: number): number {
51+
// Normalize RGB values to 0-1 range
52+
const rNorm = r / 255;
53+
const gNorm = g / 255;
54+
const bNorm = b / 255;
55+
56+
// Apply luminance formula
57+
const luminance = 0.2126 * rNorm + 0.7152 * gNorm + 0.0722 * bNorm;
58+
59+
return luminance;
60+
}
61+
62+
/**
63+
* Convert HEX color to RGBA
64+
*
65+
* @param color hex color, 6 or 8 characters (ffffff or ffffff22)
66+
* @returns 4-element array with RGBA values
67+
*/
68+
function hexToRgba(color: string): number[] {
69+
return [
70+
parseInt(color.slice(0, 2), 16),
71+
parseInt(color.slice(2, 4), 16),
72+
parseInt(color.slice(4, 6), 16),
73+
parseInt(color.slice(6, 8) || 'ff', 16) / 255
74+
];
75+
}
76+
77+
/**
78+
* Convert RGB color to HEX color
79+
*
80+
* @param r
81+
* @param g
82+
* @param b
83+
* @returns 6-character HEX color string
84+
*/
85+
function rgbToHex(r: number, g: number, b: number): string {
86+
return [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
87+
}
88+
89+
/**
90+
* Convert RGBA color with transparency to HEX color without transparency,
91+
* based on a base color
92+
*
93+
* @param rgba 4-element array with RGBA values
94+
* @param baseColor 3-element array with RGB values
95+
* @returns 6-character HEX color string
96+
*/
97+
function opacifyColor(rgba: number[], baseColor: number[]): string {
98+
const [r, g, b, a] = rgba;
99+
const [br, bg, bb] = baseColor;
100+
const rrr = Math.round(r * a + br * (1 - a));
101+
const ggg = Math.round(g * a + bg * (1 - a));
102+
const bbb = Math.round(b * a + bb * (1 - a));
103+
104+
return rgbToHex(rrr, ggg, bbb);
105+
}
106+
107+
/**
108+
* Extract 4-element array values from CSS color function
109+
*
110+
* @param color CSS color function (e.g. `rgba(255, 0, 0, 0.5)`)
111+
* @returns 4-element array with RGBA/HSLA values
112+
*/
113+
function extractColorFunctionValues(color: string): number[] {
114+
const rgba = color
115+
.replace(/^(rgb|hsl)a?\(|\)$/g, '')
116+
.split(/[ ,\s\/]/)
117+
.filter(a => !!a)
118+
.map((c, i) => {
119+
let divider = 1;
120+
if (c.includes('%')) {
121+
divider = 100;
122+
} else if (i === 0 && color.includes('hsl')) {
123+
divider = 360;
124+
}
125+
126+
return Number(c.replace(/%|deg/, '')) / divider;
127+
});
128+
129+
if (rgba.length === 3) {
130+
rgba.push(1);
131+
}
132+
133+
if (rgba.length !== 4) {
134+
throw new Error(`unsupported color function: ${color}`);
135+
}
136+
137+
return rgba;
138+
}
139+
140+
/**
141+
* Translate CSS color to HEX color without transparency when possible
142+
*
143+
* @param color css color value (see https://developer.mozilla.org/en-US/docs/Web/CSS/color_value)
144+
* @param baseColor 6-character HEX color string
145+
* @returns 6-character HEX color string, or undefined if translation is not possible
146+
*/
147+
function translateCssColor(color: string, baseColor: string): string | undefined {
148+
// Color format is `fff`
149+
if (/^[a-f0-9]{3}$/i.test(color)) {
150+
return color
151+
.split('')
152+
.map(c => c.repeat(2))
153+
.join('');
154+
}
155+
156+
// Color format is `ffffff`
157+
if (/^[a-f0-9]{6}$/i.test(color)) {
158+
return color;
159+
}
160+
161+
// Color format is `white`
162+
if (COLOR_MAP[color]) {
163+
return COLOR_MAP[color];
164+
}
165+
166+
// Return base color, as transparency is not supported
167+
if (color == 'transparent' || color === 'none') {
168+
return baseColor;
169+
}
170+
171+
// For all remaining formats, transform to RGBA first, then remove transparency
172+
let rgba: number[] = [];
173+
174+
try {
175+
if (/^(rgb|hsl)a?/.test(color)) {
176+
// Color format is `rgb()`, `rgba()`, `hsl()` or `hsla()`
177+
rgba = extractColorFunctionValues(color);
178+
} else if (/[a-f0-9]{8}/.test(color))
179+
// Color format is `ffffff22`
180+
rgba = hexToRgba(color);
181+
182+
if (rgba.length !== 4) {
183+
throw new Error(`unable to translate color to RGBA: ${color}`);
184+
}
185+
186+
return opacifyColor(rgba, hexToRgba(baseColor || 'ffffff'));
187+
} catch (e) {
188+
console.log(e);
189+
}
190+
}
191+
192+
async function loadAndConvertSkin(skin: string) {
193+
if (skins[skin]) return;
194+
195+
try {
196+
// kebabcase only strings with uppercase, and skip name with number like `tw33t`
197+
const skinName = /[A-Z]/.test(skin) ? kebabCase(skin) : skin;
198+
const response = await fetch(`${SKINS_ROOT_PATH}${skinName}.scss`);
199+
const body = await response.text();
200+
const settings = { theme: 'light' };
201+
202+
if (response.status !== 200) {
203+
return;
204+
}
205+
206+
SKIN_COLORS.forEach(key => {
207+
const matches = body.match(new RegExp(`--${key}-(color|bg):(?<color>.*);`, 'm'));
208+
209+
if (!matches) {
210+
return;
211+
}
212+
213+
const color = translateCssColor(
214+
matches.groups.color.replace('#', '').trim().toLowerCase(),
215+
settings['bg_color']
216+
);
217+
if (!color) return;
218+
219+
settings[`${key}_color`] = color;
220+
});
221+
222+
if (settings['text_color']) {
223+
settings['content_color'] = settings['text_color'];
224+
const textRgba = hexToRgba(settings['text_color']);
225+
textRgba[3] = 0.85;
226+
227+
settings['text_color'] = opacifyColor(textRgba, hexToRgba(settings['bg_color'] || 'ffffff'));
228+
}
229+
230+
if (settings['bg_color']) {
231+
const [r, g, b] = hexToRgba(settings['bg_color']);
232+
settings['theme'] = getBrightness(r, g, b) > 0.5 ? 'light' : 'dark';
233+
}
234+
235+
skins[skin] = settings;
236+
} catch (e) {
237+
console.log(e);
238+
}
239+
}
240+
241+
async function main() {
242+
const startTime = new Date().getTime();
243+
244+
const spacesWithCustomDomain = await db.queryAsync(`
245+
SELECT
246+
id,
247+
JSON_UNQUOTE(settings->'$.skin') as skin
248+
FROM spaces
249+
WHERE
250+
settings->'$.skin' IS NOT NULL
251+
AND domain IS NOT NULL
252+
`);
253+
254+
await Promise.all(spacesWithCustomDomain.map(s => loadAndConvertSkin(s.skin)));
255+
256+
console.log(
257+
`Found ${Object.keys(skins).length} skins to import into ${
258+
spacesWithCustomDomain.length
259+
} spaces`
260+
);
261+
262+
await Promise.all(
263+
spacesWithCustomDomain.map(space => {
264+
const skin = skins[space.skin];
265+
if (!skin) {
266+
console.log(`[ERROR] skin ${space.skin} not found for ${space.id}`);
267+
return;
268+
}
269+
270+
console.log(`importing skin ${space.skin} for ${space.id}`);
271+
272+
return db.queryAsync(
273+
`
274+
INSERT INTO skins (
275+
id, bg_color, link_color, text_color, content_color,
276+
border_color, heading_color, primary_color, header_color, theme
277+
)
278+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
279+
ON DUPLICATE KEY UPDATE id=id
280+
`,
281+
[
282+
space.id,
283+
skin.bg_color,
284+
skin.link_color,
285+
skin.text_color,
286+
skin.content_color,
287+
skin.border_color,
288+
skin.heading_color,
289+
skin.primary_color,
290+
skin.header_color,
291+
skin.theme
292+
]
293+
);
294+
})
295+
);
296+
297+
console.log(`Done! ✅ in ${(Date.now() - startTime) / 1000}s`);
298+
}
299+
300+
(async () => {
301+
try {
302+
await main();
303+
process.exit(0);
304+
} catch (e) {
305+
console.error(e);
306+
process.exit(1);
307+
}
308+
})();

test/schema.sql

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,20 @@ CREATE TABLE leaderboard (
193193
INDEX last_vote (last_vote)
194194
);
195195

196+
CREATE TABLE skins (
197+
id VARCHAR(100) NOT NULL,
198+
bg_color VARCHAR(6) DEFAULT NULL,
199+
link_color VARCHAR(6) DEFAULT NULL,
200+
text_color VARCHAR(6) DEFAULT NULL,
201+
content_color VARCHAR(6) DEFAULT NULL,
202+
border_color VARCHAR(6) DEFAULT NULL,
203+
heading_color VARCHAR(6) DEFAULT NULL,
204+
primary_color VARCHAR(6) DEFAULT NULL,
205+
header_color VARCHAR(6) DEFAULT NULL,
206+
theme VARCHAR(5) NOT NULL DEFAULT 'light',
207+
PRIMARY KEY (id)
208+
);
209+
196210
CREATE TABLE options (
197211
name VARCHAR(100) NOT NULL,
198212
value VARCHAR(100) NOT NULL,

0 commit comments

Comments
 (0)