| /* |
| * Copyright 2014 Google Inc. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| * use this file except in compliance with the License. You may obtain a copy of |
| * the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| * License for the specific language governing permissions and limitations under |
| * the License. |
| */ |
| package com.google.gwt.resources.converter; |
| |
| import com.google.gwt.core.ext.TreeLogger; |
| import com.google.gwt.core.ext.TreeLogger.Type; |
| import com.google.gwt.dev.util.TextOutput; |
| import com.google.gwt.resources.css.ast.Context; |
| import com.google.gwt.resources.css.ast.CssCharset; |
| import com.google.gwt.resources.css.ast.CssDef; |
| import com.google.gwt.resources.css.ast.CssEval; |
| import com.google.gwt.resources.css.ast.CssExternalSelectors; |
| import com.google.gwt.resources.css.ast.CssFontFace; |
| import com.google.gwt.resources.css.ast.CssIf; |
| import com.google.gwt.resources.css.ast.CssMediaRule; |
| import com.google.gwt.resources.css.ast.CssNoFlip; |
| import com.google.gwt.resources.css.ast.CssPageRule; |
| import com.google.gwt.resources.css.ast.CssProperty; |
| import com.google.gwt.resources.css.ast.CssProperty.DotPathValue; |
| import com.google.gwt.resources.css.ast.CssProperty.FunctionValue; |
| import com.google.gwt.resources.css.ast.CssProperty.Value; |
| import com.google.gwt.resources.css.ast.CssRule; |
| import com.google.gwt.resources.css.ast.CssSelector; |
| import com.google.gwt.resources.css.ast.CssSprite; |
| import com.google.gwt.resources.css.ast.CssUnknownAtRule; |
| import com.google.gwt.resources.css.ast.CssUrl; |
| import com.google.gwt.thirdparty.common.css.SourceCode; |
| import com.google.gwt.thirdparty.common.css.compiler.ast.GssParser; |
| import com.google.gwt.thirdparty.common.css.compiler.ast.GssParserException; |
| import com.google.gwt.thirdparty.guava.common.base.Predicate; |
| import com.google.gwt.thirdparty.guava.common.base.Splitter; |
| import com.google.gwt.thirdparty.guava.common.base.Strings; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| /** |
| * The GssGenerationVisitor turns a css tree into a gss string. |
| */ |
| public class GssGenerationVisitor extends ExtendedCssVisitor { |
| /* templates and tokens list */ |
| private static final String AND = " && "; |
| private static final String CHARSET = "@charset \"%s\";"; |
| private static final String DEF = "@def "; |
| private static final String ELSE = "@else "; |
| private static final String ELSE_IF = "@elseif (%s)"; |
| private static final String EVAL = "eval('%s')"; |
| private static final String EXTERNAL = "@external"; |
| private static final String GWT_SPRITE = "gwt-sprite: \"%s\""; |
| private static final String IF = "@if (%s)"; |
| private static final String IMPORTANT = " !important"; |
| private static final String IS = "is(\"%s\", \"%s\")"; |
| private static final String NO_FLIP = "/* @noflip */"; |
| private static final String NOT = "!"; |
| private static final String OR = " || "; |
| private static final Pattern UNESCAPE = Pattern.compile("\\\\"); |
| private static final Pattern UNESCAPE_EXTERNAL = Pattern.compile("\\\\|@external|,|\\n|\\r"); |
| private static final String URL = "resourceUrl(\"%s\")"; |
| private static final String VALUE = "value('%s')"; |
| private static final String VALUE_WITH_SUFFIX = "value('%s', '%s')"; |
| |
| // Used to quote font family name that contains white space(s) and aren't quoted yet. |
| private static Pattern NOT_QUOTED_WITH_WITHESPACE = Pattern.compile("^[^'\"].*\\s.*[^'\"]$"); |
| |
| // Used to sanitize the boolean conditions |
| private static Pattern BANG_OPERATOR = Pattern.compile("^(!+)(.*)"); |
| |
| // GSS impose constant names to be in uppercase. This Map will contains the mapping between |
| // the name of constants defined in the CSS and the corresponding name that will be used in GSS. |
| private final Map<String, String> cssToGssConstantMapping; |
| private final TextOutput out; |
| private final boolean lenient; |
| private final TreeLogger treeLogger; |
| private final Predicate<String> simpleBooleanConditionPredicate; |
| // list of external at-rules defined inside a media at-rule. |
| // In lenient mode, these nodes will be extracted and print outside the media at-rule. |
| private final List<CssExternalSelectors> wrongExternalNodes = new |
| ArrayList<CssExternalSelectors>(); |
| |
| // list of constant definition nodes defined inside a media at-rule. |
| // In lenient mode, these nodes will be extracted and print outside the media at-rule. |
| private final List<CssDef> wrongDefNodes = new ArrayList<CssDef>(); |
| |
| private boolean insideNoFlipNode; |
| private boolean needsNewLine; |
| private boolean needsOpenBrace; |
| private boolean needsComma; |
| private boolean insideMediaAtRule; |
| // used to group a sequence of @def in one block |
| private boolean previousNodeIsDef; |
| // used to group asequence of @external in one block |
| private boolean previousNodeIsExternal; |
| |
| public GssGenerationVisitor(TextOutput out, Map<String, String> cssToGssConstantMapping, |
| boolean lenient, TreeLogger treeLogger, Predicate<String> simpleBooleanConditionPredicate) { |
| this.cssToGssConstantMapping = cssToGssConstantMapping; |
| this.out = out; |
| this.lenient = lenient; |
| this.treeLogger = treeLogger; |
| this.simpleBooleanConditionPredicate = simpleBooleanConditionPredicate; |
| } |
| |
| public String getContent() { |
| return out.toString(); |
| } |
| |
| @Override |
| public void endVisit(CssFontFace x, Context ctx) { |
| closeBrace(); |
| } |
| |
| @Override |
| public void endVisit(CssMediaRule x, Context ctx) { |
| closeBrace(); |
| |
| insideMediaAtRule = false; |
| |
| maybePrintWrongExternalNodes(); |
| maybePrintWrongDefNodes(ctx); |
| } |
| |
| @Override |
| public void endVisit(CssPageRule x, Context ctx) { |
| closeBrace(); |
| } |
| |
| @Override |
| public void endVisit(CssUnknownAtRule x, Context ctx) { |
| // The old CSS resource has no support for many at-rules, like animations. There is no way |
| // for us to parse them using the old CSS parser, so we will just output them as a string to |
| // the GSS stylesheet and hope that the GSS parser is okay with the rule |
| out.print(x.getRule()); |
| } |
| |
| @Override |
| public boolean visit(CssSprite x, Context ctx) { |
| return false; |
| } |
| |
| @Override |
| public void endVisit(CssSprite x, Context ctx) { |
| needsComma = false; |
| |
| accept(x.getSelectors()); |
| openBrace(); |
| |
| out.print(String.format(GWT_SPRITE, x.getResourceFunction().getPath())); |
| semiColon(); |
| |
| accept(x.getProperties()); |
| |
| closeBrace(); |
| } |
| |
| @Override |
| public boolean visit(CssDef x, Context ctx) { |
| printDef(x, null, "def", false); |
| |
| previousNodeIsDef = true; |
| previousNodeIsExternal = false; |
| |
| return false; |
| } |
| |
| @Override |
| public boolean visit(CssEval x, Context ctx) { |
| printDef(x, EVAL, "eval", false); |
| |
| return false; |
| } |
| |
| @Override |
| public boolean visit(CssUrl x, Context ctx) { |
| printDef(x, URL, "url", true); |
| |
| return false; |
| } |
| |
| @Override |
| public boolean visit(CssRule x, Context ctx) { |
| maybePrintNewLine(); |
| |
| needsOpenBrace = true; |
| needsComma = false; |
| needsNewLine = false; |
| previousNodeIsDef = false; |
| previousNodeIsExternal = false; |
| |
| return true; |
| } |
| |
| @Override |
| public void endVisit(CssRule x, Context ctx) { |
| // empty rule block case. |
| maybePrintOpenBrace(); |
| |
| closeBrace(); |
| |
| needsNewLine = true; |
| } |
| |
| @Override |
| public boolean visit(CssNoFlip x, Context ctx) { |
| insideNoFlipNode = true; |
| previousNodeIsDef = false; |
| previousNodeIsExternal = false; |
| return true; |
| } |
| |
| @Override |
| public boolean visit(CssExternalSelectors x, Context ctx) { |
| if (insideMediaAtRule) { |
| if (lenient) { |
| treeLogger.log(Type.WARN, "An external at-rule is not allowed inside a @media at-rule. " + |
| "The following external at-rule [" + x + "] will be moved in the upper scope"); |
| wrongExternalNodes.add(x); |
| } else { |
| treeLogger.log(Type.ERROR, "An external at-rule is not allowed inside a @media at-rule. " + |
| "[" + x + "]."); |
| throw new Css2GssConversionException("An external at-rule is not allowed inside a @media" + |
| " at-rule."); |
| } |
| } else { |
| printExternal(x); |
| } |
| |
| return false; |
| } |
| |
| @Override |
| public boolean visit(CssCharset x, Context ctx) { |
| out.print(String.format(CHARSET, x.getCharset())); |
| out.newlineOpt(); |
| |
| needsNewLine = true; |
| previousNodeIsDef = false; |
| previousNodeIsExternal = false; |
| |
| return true; |
| } |
| |
| private void maybePrintWrongExternalNodes() { |
| if (!lenient) { |
| return; |
| } |
| |
| for (CssExternalSelectors external : wrongExternalNodes) { |
| printExternal(external); |
| } |
| wrongExternalNodes.clear(); |
| } |
| |
| private void maybePrintWrongDefNodes(Context ctx) { |
| if (!lenient) { |
| return; |
| } |
| |
| for (CssDef def : wrongDefNodes) { |
| if (def instanceof CssUrl) { |
| visit((CssUrl) def, ctx); |
| } else if (def instanceof CssEval) { |
| visit((CssEval) def, ctx); |
| } else { |
| visit(def, ctx); |
| } |
| } |
| wrongDefNodes.clear(); |
| } |
| |
| private void printExternal(CssExternalSelectors x) { |
| boolean first = true; |
| for (String selector : x.getClasses()) { |
| String unescaped = unescapeExternalClass(selector); |
| if (validateExternalClass(selector) && !Strings.isNullOrEmpty(unescaped)) { |
| if (first) { |
| if (!previousNodeIsExternal) { |
| maybePrintNewLine(); |
| } |
| |
| out.print(EXTERNAL); |
| first = false; |
| } |
| |
| out.print(" "); |
| |
| boolean needQuote = selector.endsWith("*"); |
| |
| if (needQuote) { |
| out.print("'"); |
| } |
| |
| out.printOpt(unescaped); |
| |
| if (needQuote) { |
| out.print("'"); |
| } |
| } |
| } |
| |
| if (!first) { |
| semiColon(); |
| } |
| |
| previousNodeIsDef = false; |
| previousNodeIsExternal = true; |
| } |
| |
| private boolean validateExternalClass(String selector) { |
| if (selector.contains(":")) { |
| if (lenient) { |
| treeLogger.log(Type.WARN, "This invalid external selector will be skipped: " + selector); |
| return false; |
| } else { |
| throw new Css2GssConversionException( |
| "One of your external statements contains a pseudo class: " + selector); |
| } |
| } |
| return true; |
| } |
| |
| @Override |
| public void endVisit(CssNoFlip x, Context ctx) { |
| insideNoFlipNode = false; |
| } |
| |
| @Override |
| public boolean visit(CssProperty x, Context ctx) { |
| maybePrintOpenBrace(); |
| |
| StringBuilder propertyBuilder = new StringBuilder(); |
| |
| if (insideNoFlipNode) { |
| propertyBuilder.append(NO_FLIP); |
| propertyBuilder.append(' '); |
| } |
| |
| propertyBuilder.append(x.getName()); |
| propertyBuilder.append(": "); |
| |
| String valueListCss = printValuesList(x.getValues().getValues(), false); |
| |
| if ("font-family".equals(x.getName())) { |
| // Font family names containing whitespace should be quoted. |
| valueListCss = quoteFontFamilyWithWhiteSpace(valueListCss); |
| } |
| |
| propertyBuilder.append(valueListCss); |
| |
| if (x.isImportant()) { |
| propertyBuilder.append(IMPORTANT); |
| } |
| |
| String cssProperty = propertyBuilder.toString(); |
| |
| // See if we can parse the rule using the GSS parser and thus verify that the |
| // rule is indeed correct CSS. |
| try { |
| new GssParser(new SourceCode(null, "body{" + cssProperty + "}")).parse(); |
| } catch (GssParserException e) { |
| if (lenient) { |
| // print a warning message and don't print the rule. |
| treeLogger.log(Type.WARN, "The following rule is not valid and will be skipped: " + |
| cssProperty); |
| return false; |
| } else { |
| treeLogger.log(Type.ERROR, "The following rule is not valid. " + |
| cssProperty); |
| throw new Css2GssConversionException("Invalid css rule", e); |
| } |
| } |
| |
| out.print(cssProperty); |
| |
| semiColon(); |
| |
| return true; |
| } |
| |
| /** |
| * Quotes the font family names that contains white space but aren't quoted yet. thus allowing |
| * usage of fonts that might be mistaken for constants. RTis is also recommended by the CSS |
| * specification: http://www.w3.org/TR/CSS2/fonts.html#propdef-font-family |
| * <p>It's important to notice that the converter doesn't manage the case where a constant is |
| * used inside a font family name with whitespace. The font family name will be quoted and |
| * won't be replaced. |
| * {@code |
| * @def myFontFamily Comic; |
| * |
| * .div { |
| * font-family: Arial, myFontFamily sans MS; |
| * } |
| * } |
| * |
| * will be converted to: |
| * |
| * {@code |
| * @def MY_FONT_FAMILY Comic; |
| * |
| * .div { |
| * font-family: Arial, "MY_FONT_FAMILY sans MS"; |
| * } |
| * } |
| * @param cssProperty |
| */ |
| private String quoteFontFamilyWithWhiteSpace(String cssProperty) { |
| StringBuilder valueBuilder = new StringBuilder(); |
| |
| boolean first = true; |
| |
| for (String subProperty : Splitter.on(",").trimResults().omitEmptyStrings().split(cssProperty)) { |
| if (first) { |
| first = false; |
| } else { |
| valueBuilder.append(","); |
| } |
| |
| if (NOT_QUOTED_WITH_WITHESPACE.matcher(subProperty).matches()) { |
| valueBuilder.append("'" + subProperty + "'"); |
| } else { |
| valueBuilder.append(subProperty); |
| } |
| } |
| |
| return valueBuilder.toString(); |
| } |
| |
| @Override |
| public boolean visit(CssElse x, Context ctx) { |
| closeBrace(); |
| |
| out.print(ELSE); |
| |
| openBrace(); |
| |
| needsNewLine = false; |
| previousNodeIsDef = false; |
| previousNodeIsExternal = false; |
| |
| return true; |
| } |
| |
| @Override |
| public boolean visit(CssElIf x, Context ctx) { |
| closeBrace(); |
| |
| openConditional(ELSE_IF, x); |
| |
| return true; |
| } |
| |
| @Override |
| public void endVisit(CssIf x, Context ctx) { |
| closeBrace(); |
| needsNewLine = true; |
| } |
| |
| @Override |
| public boolean visit(CssIf x, Context ctx) { |
| maybePrintNewLine(); |
| |
| openConditional(IF, x); |
| |
| return true; |
| } |
| |
| private void openConditional(String template, CssIf ifOrElif) { |
| String condition; |
| |
| String runtimeCondition = extractExpression(ifOrElif); |
| |
| if (runtimeCondition != null) { |
| if (simpleBooleanConditionPredicate.apply(runtimeCondition)) { |
| condition = runtimeCondition; |
| } else { |
| condition = String.format(EVAL, runtimeCondition); |
| } |
| } else { |
| condition = printConditionnalExpression(ifOrElif); |
| } |
| |
| out.print(String.format(template, condition)); |
| |
| openBrace(); |
| |
| needsNewLine = false; |
| previousNodeIsDef = false; |
| previousNodeIsExternal = false; |
| } |
| |
| private String extractExpression(CssIf ifOrElif) { |
| String condition = ifOrElif.getExpression(); |
| |
| if (condition == null) { |
| return null; |
| } |
| |
| if (condition.trim().startsWith("(")) { |
| condition = condition.substring(1, condition.length() - 1); |
| } |
| |
| // sanitize the expression. GSS doesn't accept more than one ! operator |
| Matcher m = BANG_OPERATOR.matcher(condition); |
| if (m.matches()) { |
| String bangs = m.group(1); |
| String replacement; |
| if (bangs.length() % 2 == 0) { |
| replacement = ""; |
| } else { |
| replacement = "!"; |
| } |
| |
| condition = m.replaceFirst(replacement + "$2"); |
| } |
| |
| return condition; |
| } |
| |
| @Override |
| public boolean visit(CssFontFace x, Context ctx) { |
| out.print("@font-face"); |
| |
| openBrace(); |
| |
| previousNodeIsDef = false; |
| previousNodeIsExternal = false; |
| |
| return true; |
| } |
| |
| @Override |
| public boolean visit(CssMediaRule x, Context ctx) { |
| maybePrintNewLine(); |
| |
| insideMediaAtRule = true; |
| |
| out.print("@media"); |
| boolean isFirst = true; |
| for (String m : x.getMedias()) { |
| if (isFirst) { |
| out.print(" "); |
| isFirst = false; |
| } else { |
| comma(); |
| } |
| out.print(m); |
| } |
| spaceOpt(); |
| out.print("{"); |
| out.newlineOpt(); |
| out.indentIn(); |
| |
| needsNewLine = false; |
| previousNodeIsDef = false; |
| previousNodeIsExternal = false; |
| |
| return true; |
| } |
| |
| @Override |
| public boolean visit(CssPageRule x, Context ctx) { |
| out.print("@page"); |
| if (x.getPseudoPage() != null) { |
| out.print(" :"); |
| out.print(x.getPseudoPage()); |
| } |
| spaceOpt(); |
| out.print("{"); |
| out.newlineOpt(); |
| out.indentIn(); |
| |
| previousNodeIsDef = false; |
| previousNodeIsExternal = false; |
| |
| return true; |
| } |
| |
| @Override |
| public boolean visit(CssSelector x, Context ctx) { |
| if (needsComma) { |
| comma(false); |
| } |
| |
| maybePrintNewLine(); |
| |
| needsComma = true; |
| |
| needsNewLine = true; |
| |
| out.print(unescape(x.getSelector())); |
| |
| return true; |
| } |
| |
| private void printDef(CssDef def, String valueTemplate, String atRule, boolean insideUrlNode) { |
| if (validateDefNode(def, atRule)) { |
| if (!previousNodeIsDef) { |
| maybePrintNewLine(); |
| } |
| |
| out.print(DEF); |
| |
| String name = cssToGssConstantMapping.get(def.getKey()); |
| |
| if (name == null) { |
| throw new Css2GssConversionException("unknown @" + atRule + " rule [" + def.getKey() + "]"); |
| } |
| |
| out.print(name); |
| out.print(' '); |
| |
| String values = printValuesList(def.getValues(), insideUrlNode); |
| |
| if (valueTemplate != null) { |
| out.print(String.format(valueTemplate, values)); |
| } else { |
| out.print(values); |
| } |
| |
| semiColon(); |
| |
| previousNodeIsDef = true; |
| needsNewLine = true; |
| } |
| } |
| |
| private boolean validateDefNode(CssDef def, String atRule) { |
| if (insideMediaAtRule) { |
| if (lenient) { |
| treeLogger.log(Type.WARN, "A " + atRule + " is not allowed inside a @media at-rule." + |
| "The following " + atRule + " [" + def + "] will be moved in the upper scope"); |
| wrongDefNodes.add(def); |
| return false; |
| } else { |
| treeLogger.log(Type.ERROR, "A " + atRule + " is not allowed inside a @media at-rule. [" |
| + def + "]"); |
| throw new Css2GssConversionException("A " + atRule + " is not allowed inside a @media " + |
| "at-rule."); |
| } |
| } |
| return true; |
| } |
| |
| private void closeBrace() { |
| out.indentOut(); |
| out.print('}'); |
| out.newlineOpt(); |
| } |
| |
| private void comma() { |
| comma(true); |
| } |
| |
| private void comma(boolean addSpace) { |
| out.print(','); |
| |
| if (addSpace) { |
| spaceOpt(); |
| } |
| } |
| |
| private void openBrace() { |
| spaceOpt(); |
| out.print('{'); |
| out.newlineOpt(); |
| out.indentIn(); |
| } |
| |
| private void semiColon() { |
| out.print(';'); |
| out.newlineOpt(); |
| } |
| |
| private void spaceOpt() { |
| out.printOpt(' '); |
| } |
| |
| private void maybePrintOpenBrace() { |
| if (needsOpenBrace) { |
| openBrace(); |
| needsOpenBrace = false; |
| } |
| } |
| |
| private void maybePrintNewLine() { |
| if (needsNewLine) { |
| out.newlineOpt(); |
| } |
| } |
| |
| private String printConditionnalExpression(CssIf x) { |
| if (x == null || x.getExpression() != null) { |
| throw new IllegalStateException(); |
| } |
| |
| StringBuilder builder = new StringBuilder(); |
| |
| String propertyName = x.getPropertyName(); |
| |
| for (String propertyValue : x.getPropertyValues()) { |
| if (builder.length() != 0) { |
| if (x.isNegated()) { |
| builder.append(AND); |
| } else { |
| builder.append(OR); |
| } |
| } |
| |
| if (x.isNegated()) { |
| builder.append(NOT); |
| } |
| |
| builder.append(String.format(IS, propertyName, propertyValue)); |
| } |
| |
| return builder.toString(); |
| } |
| |
| private String printValuesList(List<Value> values, boolean insideUrlNode) { |
| StringBuilder builder = new StringBuilder(); |
| |
| for (Value value : values) { |
| if (value.isSpaceRequired() && builder.length() != 0) { |
| builder.append(' '); |
| } |
| |
| String expression = value.toCss(); |
| |
| if (value.isIdentValue() != null && cssToGssConstantMapping.containsKey(expression)) { |
| expression = cssToGssConstantMapping.get(expression); |
| } else if (value.isExpressionValue() != null) { |
| expression = value.getExpression(); |
| } else if (value.isDotPathValue() != null) { |
| DotPathValue dotPathValue = value.isDotPathValue(); |
| if (insideUrlNode) { |
| expression = dotPathValue.getPath(); |
| } else { |
| if (Strings.isNullOrEmpty(dotPathValue.getSuffix())) { |
| expression = String.format(VALUE, dotPathValue.getPath()); |
| } else { |
| expression = |
| String.format(VALUE_WITH_SUFFIX, dotPathValue.getPath(), dotPathValue.getSuffix()); |
| } |
| } |
| } else if (value.isFunctionValue() != null) { |
| FunctionValue functionValue = value.isFunctionValue(); |
| |
| // process the argument list values |
| String arguments = printValuesList(functionValue.getValues().getValues(), insideUrlNode); |
| |
| expression = unescape(functionValue.getName()) + "(" + arguments + ")"; |
| } |
| |
| // don't escape content of quoted string and don't escape isFunctionValue because the |
| // arguments and the name of the functions are already unescaped if needed. |
| if (value.isStringValue() != null || value.isFunctionValue() != null) { |
| builder.append(expression); |
| } else { |
| builder.append(unescape(expression)); |
| } |
| } |
| |
| return builder.toString(); |
| } |
| |
| private String unescape(String toEscape) { |
| return UNESCAPE.matcher(toEscape).replaceAll(""); |
| } |
| |
| private String unescapeExternalClass(String external) { |
| return UNESCAPE_EXTERNAL.matcher(external).replaceAll(""); |
| } |
| } |