Automattic / juice

Juice inlines CSS stylesheets into your HTML source.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Support for CSS Custom Variables

mjgchase opened this issue · comments

Would be nice to support and convert css custom variables
https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_variables

Since email clients have generally poor support for CSS variables, it would be nice if Juice would inline them using getComputedStyle.

commented

I agree - passing on the variable doesn't work, so computing it and inlining the computed value would be stellar. I have tested variables and it didn't work as expected, with missing styles on the email client side.

Any ETA on implementing this? Very important as I use components and have colors, borders, etc defined once and used everywhere, including email templates.

@huksley would love to take a PR from you if you have time to do it!

Here's a solution for replacing the CSS variables after juice is done, using cheerio:

const variableDefRegex = /^(--[a-zA-Z0-9-_]+)$/;
const variableUsageRegex = /var\((--[a-zA-Z0-9-_]+)(?:\)|,\s*(.*)\))/;
/**
 * Resolves any variable usages found in a style value to their definitions.
 */
export function resolveCSSVariables(defs: Map<string, string>, styleValue = ''): string {
  let match;
  while (match = styleValue.match(variableUsageRegex)) {
    const [found, variableName, fallback] = match;
    const { index } = match;
    const before = styleValue.slice(0, index);
    const after = styleValue.slice(index! + found.length);
    const replacement = defs.has(variableName)  ? defs.get(variableName) : fallback;
    styleValue = `${before}${resolveCSSVariables(defs, replacement || '')}${after}`;
  }
  return styleValue;
}

/**
 * Walks the tree depth-first,
 * collecting variable definitions and then resolving any usages found.
 */
export function replaceCSSVariables($: CheerioAPI, $el : Cheerio<any>, defs = new Map<string, string>()) {
  const styles = $el.css();
  if (styles) {
    /**
     * Collect the defs first.
     */
    Object.entries(styles).forEach(([key, value]) => {
      if (variableDefRegex.test(key)) {
        defs.set(key, value);
      }
    });
    /**
     * Resolve any usages.
     * Done separately from above in case any defs and usages are found on the same element.
     */
    Object.entries(styles).forEach(([key, value]) => {
      styles[key] = resolveCSSVariables(defs, value);
    });
    $el.css(styles);
  }
  $el.children().each(
    (index, el) => replaceCSSVariables($, $(el), new Map<string, string>(defs)),
  );
}

export default function inlineWithVariablesReplaced(html: string, css: string): string {
  const inlined = juice.inlineContent(html, css, { inlinePseudoElements: true });
  const $ = cheerio.load(inlined, null, false);
  const $root = $.root();
  replaceCSSVariables($, $root);
  return cheerio.html($root);
}

Here's a solution for replacing the CSS variables after juice is done, using cheerio:

const variableDefRegex = /^(--[a-zA-Z0-9-_]+)$/;
const variableUsageRegex = /var\((--[a-zA-Z0-9-_]+)(?:\)|,\s*(.*)\))/;
/**
 * Resolves any variable usages found in a style value to their definitions.
 */
export function resolveCSSVariables(defs: Map<string, string>, styleValue = ''): string {
  let match;
  while (match = styleValue.match(variableUsageRegex)) {
    const [found, variableName, fallback] = match;
    const { index } = match;
    const before = styleValue.slice(0, index);
    const after = styleValue.slice(index! + found.length);
    const replacement = defs.has(variableName)  ? defs.get(variableName) : fallback;
    styleValue = `${before}${resolveCSSVariables(defs, replacement || '')}${after}`;
  }
  return styleValue;
}

/**
 * Walks the tree depth-first,
 * collecting variable definitions and then resolving any usages found.
 */
export function replaceCSSVariables($: CheerioAPI, $el : Cheerio<any>, defs = new Map<string, string>()) {
  const styles = $el.css();
  if (styles) {
    /**
     * Collect the defs first.
     */
    Object.entries(styles).forEach(([key, value]) => {
      if (variableDefRegex.test(key)) {
        defs.set(key, value);
      }
    });
    /**
     * Resolve any usages.
     * Done separately from above in case any defs and usages are found on the same element.
     */
    Object.entries(styles).forEach(([key, value]) => {
      styles[key] = resolveCSSVariables(defs, value);
    });
    $el.css(styles);
  }
  $el.children().each(
    (index, el) => replaceCSSVariables($, $(el), new Map<string, string>(defs)),
  );
}

export default function inlineWithVariablesReplaced(html: string, css: string): string {
  const inlined = juice.inlineContent(html, css, { inlinePseudoElements: true });
  const $ = cheerio.load(inlined, null, false);
  const $root = $.root();
  replaceCSSVariables($, $root);
  return cheerio.html($root);
}

This is great. Am I correct in understanding, though, that if the variable is defined in various classes that apply to an element, this will just use the most-recently-encountered variable definition that matches, rather than the definition from the highest-priority class?