Skip to content
Snippets Groups Projects
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();