1   package org.daisy.pipeline.css.sass;
2   
3   import java.io.BufferedReader;
4   import java.io.ByteArrayInputStream;
5   import java.io.File;
6   import java.io.IOException;
7   import java.io.InputStream;
8   import java.io.StringReader;
9   import java.net.URI;
10  import java.net.URISyntaxException;
11  import java.net.URL;
12  import java.net.URLConnection;
13  import java.nio.charset.Charset;
14  import java.nio.charset.StandardCharsets;
15  import java.nio.charset.UnsupportedCharsetException;
16  import java.util.Base64;
17  import java.util.Collection;
18  import java.util.Map;
19  import java.util.regex.Matcher;
20  import java.util.regex.Pattern;
21  import java.util.zip.GZIPInputStream;
22  
23  import javax.xml.transform.Source;
24  import javax.xml.transform.TransformerException;
25  import javax.xml.transform.URIResolver;
26  import javax.xml.transform.stream.StreamSource;
27  
28  import com.google.common.collect.ImmutableList;
29  import com.google.common.collect.ImmutableMap;
30  import com.google.common.io.ByteSource;
31  import com.google.common.io.CharStreams;
32  
33  import io.bit3.jsass.CompilationException;
34  import io.bit3.jsass.Compiler;
35  import io.bit3.jsass.importer.Import;
36  import io.bit3.jsass.importer.Importer;
37  import io.bit3.jsass.Options;
38  import io.bit3.jsass.Output;
39  import io.bit3.jsass.OutputStyle;
40  
41  import org.antlr.runtime.ANTLRInputStream;
42  import org.antlr.runtime.CommonTokenStream;
43  import org.antlr.runtime.RecognitionException;
44  
45  import org.daisy.common.file.URLs;
46  import org.daisy.pipeline.css.CssPreProcessor;
47  import org.daisy.pipeline.css.sass.impl.SassPostProcessLexer;
48  import org.daisy.pipeline.css.sass.impl.SassPostProcessParser;
49  
50  import org.slf4j.Logger;
51  import org.slf4j.LoggerFactory;
52  
53  /**
54   * {@link CssPreProcessor} that handles media type "text/x-scss".
55   */
56  public class SassCompiler implements CssPreProcessor {
57  
58  	public boolean supportsMediaType(String mediaType, URL url) {
59  		if ("text/x-scss".equals(mediaType))
60  			return true;
61  		else if (mediaType == null && url != null && url.toString().endsWith(".scss"))
62  			return true;
63  		else
64  			return false;
65  	}
66  
67  	private final Importer importer;
68  	private final StreamSourceURIResolver resolver;
69  	private final Map<String,String> env;
70  
71  	/**
72  	 * @param env SASS variables. The map is allowed to be mutated by the caller after the
73  	 *            SassCompiler is created.
74  	 */
75  	public SassCompiler(final URIResolver resolver, Map<String,String> env) {
76  		this.resolver = new StreamSourceURIResolver(resolver);
77  		this.env = env;
78  		importer = new Importer() {
79  				public Collection<Import> apply(String url, Import previous) {
80  					URI uri = URLs.asURI(url);
81  					URI base = previous.getAbsoluteUri();
82  					if (!base.isAbsolute()) // why is scheme dropped?
83  						try {
84  							base = new URI("file", base.getAuthority(), base.getPath(), base.getQuery(), base.getFragment());
85  						} catch (URISyntaxException e) {
86  							throw new IllegalStateException("coding error");
87  						}
88  					logger.debug("Importing SASS style sheet: " + uri + " (base = " + base + ")");
89  					try {
90  						try {
91  							StreamSource resolved = SassCompiler.this.resolver.resolve(uri.toString(), base.toString());
92  							URI abs = URLs.asURI(resolved.getSystemId());
93  							logger.debug("Resolved to: " + abs);
94  							try {
95  								return ImmutableList.of(
96  									new Import(uri, abs,
97  									           preProcess(
98  									               byteSource(resolved.getInputStream())
99  									               // why are we assuming UTF-8?
100 									               .asCharSource(StandardCharsets.UTF_8).read()))); }
101 							catch (RuntimeException e) {
102 								throw new IOException(e); }}
103 						catch (TransformerException e) {
104 							throw new IOException(e); }}
105 					catch (IOException e) {
106 						if (!url.endsWith(".scss"))
107 							return apply(url + ".scss", previous);
108 						else
109 							throw new RuntimeException("Failed to import " + uri + " (base = " + base + ")", e); }
110 				}
111 			};
112 	}
113 
114 	private static final String scssNumber = "\\d*\\.\\d+";
115 	private static final String scssColor = "(#[\\da-zA-Z]+|(rgb|hsl)a?\\([^)]*\\))";
116 	private static final String scssBadStringChars = "!\"#$'()*+,\\.\\/:<=>?@\\[\\\\\\]^`{|}~-";
117 	private static final String scssNumberColorString = "\\s*("+ scssNumber
118 	                                                       +"|"+ scssColor
119 	                                                       +"|[^\\s"+scssBadStringChars+"]+"
120 	                                                       +"|\"([^\"]|\\\")*\""
121 	                                                       +"|'([^']|\\')*'"
122 	                                                       +")\\s*";
123 	private static final Pattern charsetRule = Pattern.compile("(@charset +\"(.+)\";?).*");
124 	private static final Pattern sourceMappingComment
125 		= Pattern.compile("/\\*# sourceMappingURL=data:application/json;base64,(.+)=* \\*/");
126 	private static final Base64.Decoder base64Decoder = Base64.getDecoder();
127 	
128 	/**
129 	 * @throws IOException if something goes wrong reading the input
130 	 * @throws RuntimeException if the compilation fails.
131 	 */
132 	@Override
133 	public PreProcessingResult compile(PreProcessingSource source) throws IOException {
134 		Compiler sassCompiler = new Compiler();
135 		Options options = new Options();
136 		options.setIsIndentedSyntaxSrc(false);
137 		options.setOutputStyle(OutputStyle.EXPANDED);
138 		options.setSourceMapContents(false);
139 		options.setSourceMapEmbed(true);
140 		options.setSourceComments(false);
141 		options.setPrecision(5);
142 		options.setOmitSourceMapUrl(false);
143 		options.getImporters().add(importer);
144 		// FIXME: Note that the addition of these variables breaks the original line info in
145 		// sourceMap. Luckily this is not a real problem because only the base info is used.
146 		StringBuilder scss = new StringBuilder();
147 		if (env != null) {
148 			for (String var : env.keySet()) {
149 				String value = env.get(var);
150 				if (!value.matches(scssNumberColorString)) {
151 					// if value contains spaces or special characters that can mess up parsing; wrap it in single quotes
152 					logger.debug("scss variable '"+var+"' contains special characters: "+value);
153 					value = "'"+value.replace("\n", "\\A").replace("'","\\27")+"'";
154 					logger.debug("scss variable '"+var+"' was quoted                 : "+value);
155 				} else {
156 					logger.debug("scss variable '"+var+"' contains no special characters: "+value);
157 				}
158 				scss.append("$").append(var).append(": ").append(value).append(";\n");
159 			}
160 		}
161 		BufferedReader stream = new BufferedReader(source.stream);
162 		String firstLine = stream.readLine();
163 		Matcher m = charsetRule.matcher(firstLine);
164 		if (m.matches()) {
165 			String charset = m.group(2);
166 			firstLine = firstLine.substring(m.group(1).length());
167 			try {
168 				stream = new BufferedReader(source.reread(Charset.forName(charset)));
169 				firstLine = stream.readLine();
170 				m = charsetRule.matcher(firstLine);
171 				if (m.matches()) { // must be true
172 					firstLine = firstLine.substring(m.group(1).length());
173 				}
174 			} catch (UnsupportedCharsetException e) {
175 				logger.warn("Ignoring @charset \"" + charset + "\";");
176 			} catch (IOException e) {
177 				logger.warn("Ignoring @charset \"" + charset + "\";");
178 			}
179 		}
180 		scss.append(firstLine).append("\n");
181 		scss.append(CharStreams.toString(stream));
182 		stream.close();
183 		try {
184 			// FIXME: Note that preProcess() breaks the original column info in sourceMap. Luckily
185 			// this is not a real problem because only the base info is used.
186 			Output result = sassCompiler.compileString(preProcess(scss.toString()), source.base, null, options);
187 			String css = result.getCss();
188 			String sourceMap = null; {
189 				int lastNewlineIdx = css.lastIndexOf('\n');
190 				String lastLine = css.substring(lastNewlineIdx + 1);
191 				if ((m = sourceMappingComment.matcher(lastLine)).matches()) {
192 					sourceMap = new String(base64Decoder.decode(m.group(1)));
193 					css = css.substring(0, lastNewlineIdx);
194 				}
195 			}
196 			// FIXME: Note that postProcess() breaks the column info in sourceMap. Luckily this only
197 			// happens in selectors, not in url() values.
198 			css = postProcess(css);
199 			logger.debug(source.base + " compiled to:\n---\n" + css + "---\n");
200 			return new PreProcessingResult(
201 				new StringReader(css),
202 				sourceMap,
203 				// in source map files are relative to the current working directory
204 				sourceMap != null
205 					? URLs.asURI(new File("").getAbsoluteFile())
206 					: source.base); }
207 		catch (CompilationException e) {
208 			throw new RuntimeException("Could not compile SASS style sheet", e); }
209 	}
210 
211 	private static ByteSource byteSource(final InputStream is) {
212 		return new ByteSource() {
213 			public InputStream openStream() throws IOException {
214 				return is;
215 			}
216 		};
217 	}
218 
219 	/**
220 	 * URIResolver that is guaranteed to return a StreamSource with a systemId.
221 	 * Falls back to URL.openStream() if the backing resolver can not resolve the URI.
222 	 * Throws a TransformerException if an error happens in the backing resolver or the resource can not be found.
223 	 */
224 	private static class StreamSourceURIResolver implements URIResolver {
225 
226 		final URIResolver resolver;
227 
228 		StreamSourceURIResolver(URIResolver resolver) {
229 			this.resolver = resolver;
230 		}
231 
232 		@Override
233 		public StreamSource resolve(String href, String base) throws TransformerException {
234 			Source source = resolver != null ? resolver.resolve(href, base) : null;
235 			String systemId = null;
236 			if (source != null)
237 				systemId = source.getSystemId();
238 			if (systemId == null || systemId.equals(""))
239 				systemId = null;
240 			if (source != null && source instanceof StreamSource && systemId != null)
241 				return (StreamSource)source;
242 			if (systemId == null)
243 				systemId = URLs.resolve(URLs.asURI(base), URLs.asURI(href)).toASCIIString();
244 			InputStream stream;
245 			if (source != null && source instanceof StreamSource)
246 				stream = ((StreamSource)source).getInputStream();
247 			else
248 				try {
249 					URLConnection conn = URLs.asURL(systemId).openConnection();
250 					stream = conn.getInputStream();
251 					if ("gzip".equalsIgnoreCase(conn.getContentEncoding()))
252 						stream = new GZIPInputStream(stream); }
253 				catch (IOException e) {
254 					throw new TransformerException(e); }
255 			return new StreamSource(stream, systemId);
256 		}
257 	}
258 
259 	private static final Logger logger = LoggerFactory.getLogger(SassCompiler.class);
260 
261 	/**
262 	 * In order to fully support stacked pseudo-elements and pseudo-classes on pseudo-elements (also
263 	 * in combination with @extend), we need to pre-processed the SASS before it is compiled to
264 	 * CSS. Pseudo-elements are replaced with a child selector followed by a pseudo-element. This is
265 	 * reverted in {@link #postProcess(String)}.
266 	 */
267 	private static String preProcess(String sass) {
268 		return sass.replaceAll("::", ">::");
269 	}
270 
271 	/**
272 	 * Replace child selector followed by a pseudo-element with the pseudo-element.
273 	 */
274 	private static String postProcess(String css) {
275 		if (!css.contains("::"))
276 			return css;
277 		try {
278 			ANTLRInputStream input;
279 			try {
280 				input = new ANTLRInputStream(
281 					new ByteArrayInputStream(css.getBytes(StandardCharsets.UTF_8)),
282 					StandardCharsets.UTF_8.name());
283 			} catch (IOException e) {
284 				throw new RuntimeException(e); // should not happen
285 			}
286 			SassPostProcessLexer lexer = new SassPostProcessLexer(input);
287 			CommonTokenStream tokens = new CommonTokenStream(lexer);
288 			SassPostProcessParser parser = new SassPostProcessParser(tokens);
289 			return parser.stylesheet();
290 		} catch (RecognitionException e) {
291 			throw new RuntimeException("Error happened while parsing the CSS", e);
292 		}
293 	}
294 }