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
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
73
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())
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
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
130
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
145
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
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()) {
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
185
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
197
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
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
221
222
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
263
264
265
266
267 private static String preProcess(String sass) {
268 return sass.replaceAll("::", ">::");
269 }
270
271
272
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);
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 }