-
Tom Robinson authoredTom Robinson authored
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
colopocalypse 10.75 KiB
#!./node_modules/.bin/babel-node
const glob = require("glob");
const fs = require("fs");
const path = require("path");
const Color = require("color");
const colorDiff = require("color-diff");
const _ = require("underscore");
const j = require("jscodeshift");
const { replaceStrings } = require("./lib/codemod");
const POSTCSS_CONFIG = require("../postcss.config.js");
const cssVariables =
POSTCSS_CONFIG.plugins["postcss-cssnext"].features.customProperties.variables;
// console.log(cssVariables);
// these are a bit liberal regexes but that's probably ok
const COLOR_REGEX = /(?:#[a-fA-F0-9]{3}(?:[a-fA-F0-9]{3})?\b|(?:rgb|hsl)a?\(\s*\d+\s*(?:,\s*\d+(?:\.\d+)?%?\s*){2,3}\))/g;
const COLOR_REGEX_WITH_LINE = /(?:#[a-fA-F0-9]{3}(?:[a-fA-F0-9]{3})?\b|(?:rgb|hsl)a?\(\s*\d+\s*(?:,\s*\d+(?:\.\d+)?%?\s*){2,3}\)).*/g;
const CSS_SIMPLE_VAR_REGEX = /^var\(([^)]+)\)$/;
const CSS_COLOR_VAR_REGEX = /^color\(var\(([^)]+)\) shade\(([^)]+)\)\)$/;
const CSS_VAR_REGEX = /var\([^)]+\)|color\(var\([^)]+\) shade\([^)]+\)\)/g;
const FILE_GLOB = "frontend/src/**/*.{css,js,jsx}";
const FILE_GLOB_IGNORE = [
"**/metabase/lib/colors.js",
"**/metabase/css/core/colors.css",
"**/metabase/auth/components/AuthScene.jsx",
"**/metabase/icon_paths.js",
// // recast messes up these file and they don't have any colors so just ignore them:
// "**/metabase/query_builder/components/FieldList.jsx",
// "**/metabase/query_builder/components/filters/FilterPopover.jsx",
// "**/metabase/visualizations/components/TableInteractive.jsx",
];
const COLORS_CSS_PATH = "frontend/src/metabase/css/core/colors.css";
const COLORS_JS_PATH = "frontend/src/metabase/lib/colors.js";
const varForName = name => `--color-${name}`;
const colors = {
// themeable colors
brand: "#509EE3",
accent1: "#9CC177",
accent2: "#A989C5",
accent3: "#EF8C8C",
accent4: "#F9D45C",
accent5: "#F1B556",
accent6: "#A6E7F3",
accent7: "#7172AD",
// general purpose
white: "#FFFFFF",
black: "#2E353B",
// semantic colors
success: "#84BB4C",
error: "#ED6E6E",
warning: "#F9CF48",
"text-dark": "#2E353B", // "black"
"text-medium": "#93A1AB",
"text-light": "#DCE1E4",
"text-white": "#FFFFFF", // "white"
"bg-black": "#2E353B", // "black"
"bg-dark": "#93A1AB",
"bg-medium": "#EDF2F5",
"bg-light": "#F9FBFC",
"bg-white": "#FFFFFF", // "white"
shadow: "#F4F5F6",
border: "#D7DBDE",
};
function paletteForColors(colors) {
return Object.entries(colors).map(([name, colorValue]) => {
const color = Color(colorValue);
return {
name,
color,
R: color.red(),
G: color.green(),
B: color.blue(),
};
});
}
const PRIMARY_AND_SECONDARY_NAMES = [
"brand",
"accent1",
"accent2",
"accent3",
"accent4",
];
const TEXT_COLOR_NAMES = [
"text-dark",
"text-medium",
"text-light",
"text-white",
];
const BACKGROUND_COLOR_NAMES = [
"bg-black",
"bg-dark",
"bg-medium",
"bg-light",
"bg-white",
];
const SEMANTIC_NAMES = ["success", "error", "warning"];
const PALETTE_FOREGROUND = paletteForColors(
_.pick(
colors,
...TEXT_COLOR_NAMES,
...PRIMARY_AND_SECONDARY_NAMES,
...SEMANTIC_NAMES,
),
);
const PALETTE_BACKGROUND = paletteForColors(
_.pick(
colors,
...BACKGROUND_COLOR_NAMES,
...PRIMARY_AND_SECONDARY_NAMES,
...SEMANTIC_NAMES,
),
);
const PALETTE_BORDER = paletteForColors(
_.pick(colors, "border", ...PRIMARY_AND_SECONDARY_NAMES),
);
const PALETTE_SHADOW = paletteForColors(_.pick(colors, "shadow"));
// basically everything except border/shadow
const PALETTE_OTHER = paletteForColors(
_.pick(
colors,
...TEXT_COLOR_NAMES,
...BACKGROUND_COLOR_NAMES,
...PRIMARY_AND_SECONDARY_NAMES,
...SEMANTIC_NAMES,
),
);
function paletteForCSSProperty(property) {
if (property) {
if (property === "color" || /text|font/i.test(property)) {
return PALETTE_FOREGROUND;
} else if (/bg|background/i.test(property)) {
return PALETTE_BACKGROUND;
} else if (/border/i.test(property)) {
return PALETTE_BORDER;
} else if (/shadow/i.test(property)) {
return PALETTE_SHADOW;
}
}
if (property != undefined) {
console.log("unknown pallet for property", property);
}
return PALETTE_OTHER;
}
function getBestCandidate(color, palette) {
const closest = colorDiff.closest(
{ R: color.red(), G: color.green(), B: color.blue() },
palette,
);
let bestName = closest.name;
let bestColor = closest.color;
if (color.alpha() < 1) {
bestColor = bestColor.alpha(color.alpha());
}
return [bestName, bestColor];
}
function toJSValue(newColorName, newColor) {
if (newColor.alpha() < 1) {
return newColor.string();
} else {
return newColor.hex();
}
}
function toCSSValue(newColorName, newColor) {
if (newColor.alpha() < 1) {
return `color(var(${varForName(newColorName)}) alpha(-${Math.round(
100 * (1 - newColor.alpha()),
)}%))`;
} else {
return `var(${varForName(newColorName)})`;
}
}
function lineAtIndex(lines, index) {
let charIndex = 0;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
charIndex += lines[lineIndex].length + 1;
if (charIndex >= index) {
return lines[lineIndex];
}
}
}
function lineUpToIndex(lines, index) {
let charIndex = 0;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const lineStart = charIndex;
charIndex += lines[lineIndex].length + 1;
if (charIndex >= index) {
return lines[lineIndex].slice(0, index - lineStart);
}
}
}
function cssPropertyAtIndex(lines, index) {
const line = lineAtIndex(lines, index);
const prefix = lineUpToIndex(lines, index);
if (line) {
const match =
// matches property names at the beginning of the line
line.match(/^\s*([a-zA-Z0-9-]+):/) ||
// matches property names leading up to the rule value
prefix.match(/(^|[^a-zA-Z0-9-])([a-zA-Z0-9-]+)\s*:\s*"?$/);
if (match) {
return match[1].trim();
} else {
console.warn("no property", line);
}
} else {
console.warn("no line at that index! this should not happen");
}
}
function replaceCSSColorValues(content) {
const lines = content.split("\n");
return content.replace(COLOR_REGEX, (color, index) => {
const palette = paletteForCSSProperty(cssPropertyAtIndex(lines, index));
const [newColorName, newColor] = getBestCandidate(Color(color), palette);
return toCSSValue(newColorName, newColor);
});
}
function replaceJSColorValues(content) {
if (COLOR_REGEX.test(content)) {
// console.log("processing");
return replaceStrings(content, COLOR_REGEX, (value, propertyName) => {
const palette = paletteForCSSProperty(propertyName);
const [newColorName, newColor] = getBestCandidate(Color(value), palette);
// console.log(value, propertyName, "=>", newColorName);
// return j.identifier(newColorName.replace(/\W/g, "_"));
// return j.stringLiteral(toJSValue(newColorName, newColor));
return j.memberExpression(
j.identifier("colors"),
/\W/.test(newColorName)
? j.literal(newColorName)
: j.identifier(newColorName),
);
});
} else {
// console.log("skipping");
return content;
}
}
function replaceCSSColorVariables(content) {
const lines = content.split("\n");
return content.replace(CSS_VAR_REGEX, (variable, index) => {
const match = variable.match(/^var\(--color-(.*)\)$/);
if (match && colors[match[1]]) {
// already references a color, don't change it
return variable;
}
const color = resolveCSSVariableColor(variable);
if (color) {
const palette = paletteForCSSProperty(cssPropertyAtIndex(lines, index));
const [newColorName, newColor] = getBestCandidate(Color(color), palette);
return toCSSValue(newColorName, newColor);
} else {
return variable;
}
});
}
function resolveCSSVariableColor(value) {
try {
if (value) {
if (COLOR_REGEX.test(value)) {
return Color(value);
}
const colorVarMatch = value.match(CSS_COLOR_VAR_REGEX);
if (colorVarMatch) {
const color = resolveCSSVariableColor(cssVariables[colorVarMatch[1]]);
if (color) {
const shade = parseFloat(colorVarMatch[2]) / 100;
return Color(color).mix(Color("black"), shade);
}
}
const varMatch = value.match(CSS_SIMPLE_VAR_REGEX);
if (varMatch) {
const color = resolveCSSVariableColor(cssVariables[varMatch[1]]);
if (color) {
return color;
}
}
}
} catch (e) {
console.warn(e);
}
return null;
}
function processFiles(files) {
for (const file of files) {
let content = fs.readFileSync(file, "utf-8");
try {
if (/\.css/.test(file)) {
content = replaceCSSColorVariables(replaceCSSColorValues(content));
} else if (/\.jsx?/.test(file)) {
let newContent = replaceJSColorValues(content);
if (newContent !== content && !/\/colors.js/.test(file)) {
newContent = ensureHasColorsImport(newContent);
}
content = newContent;
} else {
console.warn("unknown file type", file);
}
fs.writeFileSync(file, content);
} catch (e) {
console.log("failed to process", file, e);
}
}
// do this last so we don't replace them
prependCSSVariablesBlock();
prependJSVariablesBlock();
}
function ensureHasColorsImport(content) {
// TODO: implement
return content;
}
function prependCSSVariablesBlock() {
const colorsVarsBlock = `
/* NOTE: DO NOT ADD COLORS WITHOUT EXTREMELY GOOD REASON AND DESIGN REVIEW
* NOTE: KEEP SYNCRONIZED WITH COLORS.JS
*/
:root {
${Object.entries(colors)
.map(([name, color]) => ` ${varForName(name)}: ${color};`)
.join("\n")}
}\n\n`;
const content = fs.readFileSync(COLORS_CSS_PATH, "utf-8");
if (content.indexOf("NOTE: DO NOT ADD COLORS") < 0) {
fs.writeFileSync(COLORS_CSS_PATH, colorsVarsBlock + content);
}
}
function prependJSVariablesBlock() {
// TODO: remove window.colors and inject `import colors from "metabase/lib/colors";` in each file where it's required
const colorsVarsBlock = `
// NOTE: DO NOT ADD COLORS WITHOUT EXTREMELY GOOD REASON AND DESIGN REVIEW
// NOTE: KEEP SYNCRONIZED WITH COLORS.CSS
const colors = window.colors = ${JSON.stringify(colors, null, 2)};
export default colors;\n\n`;
const content = fs.readFileSync(COLORS_JS_PATH, "utf-8");
if (content.indexOf("NOTE: DO NOT ADD COLORS") < 0) {
const anchor = "export const brand = ";
fs.writeFileSync(
COLORS_JS_PATH,
content.replace(anchor, colorsVarsBlock + anchor),
);
}
}
function run() {
const fileGlob = process.argv[2] || FILE_GLOB;
glob(
path.join(__dirname, "..", fileGlob),
{ ignore: FILE_GLOB_IGNORE },
(err, files) => {
if (err) {
console.error(err);
} else {
processFiles(files);
}
},
);
}
run();