1 | // Copyright (C) 2008 Google Inc. |
2 | // |
3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
4 | // you may not use this file except in compliance with the License. |
5 | // You may obtain a copy of the License at |
6 | // |
7 | // http://www.apache.org/licenses/LICENSE-2.0 |
8 | // |
9 | // Unless required by applicable law or agreed to in writing, software |
10 | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | // See the License for the specific language governing permissions and |
13 | // limitations under the License. |
14 | |
15 | package com.google.caja.plugin; |
16 | |
17 | import com.google.caja.ancillary.opt.JsOptimizer; |
18 | import com.google.caja.lexer.CharProducer; |
19 | import com.google.caja.lexer.ExternalReference; |
20 | import com.google.caja.lexer.FetchedData; |
21 | import com.google.caja.lexer.InputSource; |
22 | import com.google.caja.lexer.JsLexer; |
23 | import com.google.caja.lexer.JsTokenQueue; |
24 | import com.google.caja.lexer.ParseException; |
25 | import com.google.caja.lexer.TokenConsumer; |
26 | import com.google.caja.lexer.escaping.UriUtil; |
27 | import com.google.caja.parser.ParseTreeNode; |
28 | import com.google.caja.parser.html.DomParser; |
29 | import com.google.caja.parser.html.Namespaces; |
30 | import com.google.caja.parser.html.Nodes; |
31 | import com.google.caja.parser.js.Expression; |
32 | import com.google.caja.parser.js.Minify; |
33 | import com.google.caja.parser.js.ObjectConstructor; |
34 | import com.google.caja.parser.js.Parser; |
35 | import com.google.caja.parser.js.Statement; |
36 | import com.google.caja.reporting.MarkupRenderMode; |
37 | import com.google.caja.reporting.Message; |
38 | import com.google.caja.reporting.MessageContext; |
39 | import com.google.caja.reporting.MessageLevel; |
40 | import com.google.caja.reporting.MessagePart; |
41 | import com.google.caja.reporting.MessageQueue; |
42 | import com.google.caja.reporting.MessageType; |
43 | import com.google.caja.reporting.RenderContext; |
44 | import com.google.caja.reporting.SimpleMessageQueue; |
45 | import com.google.caja.reporting.SnippetProducer; |
46 | import com.google.caja.reporting.BuildInfo; |
47 | import com.google.caja.render.Concatenator; |
48 | import com.google.caja.render.Innocent; |
49 | import com.google.caja.render.JsMinimalPrinter; |
50 | import com.google.caja.render.JsPrettyPrinter; |
51 | import com.google.caja.tools.BuildService; |
52 | import com.google.caja.util.Charsets; |
53 | import com.google.caja.util.Lists; |
54 | import com.google.caja.util.Maps; |
55 | import com.google.caja.util.Pair; |
56 | import com.google.caja.util.Sets; |
57 | |
58 | import java.io.File; |
59 | import java.io.FileInputStream; |
60 | import java.io.FileOutputStream; |
61 | import java.io.IOException; |
62 | import java.io.InputStreamReader; |
63 | import java.io.OutputStreamWriter; |
64 | import java.io.PrintWriter; |
65 | import java.io.Reader; |
66 | import java.io.Writer; |
67 | import java.net.URI; |
68 | import java.util.Collections; |
69 | import java.util.List; |
70 | import java.util.Map; |
71 | import java.util.Set; |
72 | |
73 | import org.w3c.dom.Document; |
74 | import org.w3c.dom.Element; |
75 | import org.w3c.dom.Node; |
76 | |
77 | /** |
78 | * Build integration to {@link PluginCompiler} and {@link Minify}. |
79 | * |
80 | * @author mikesamuel@gmail.com |
81 | */ |
82 | public class BuildServiceImplementation implements BuildService { |
83 | private final Map<InputSource, String> originalSources = Maps.newHashMap(); |
84 | |
85 | /** |
86 | * Cajoles inputs to output writing any messages to logger, returning true |
87 | * iff the task passes. |
88 | */ |
89 | public boolean cajole( |
90 | PrintWriter logger, List<File> dependees, List<File> inputs, File output, |
91 | Map<String, Object> options) { |
92 | final Set<File> canonFiles = Sets.newHashSet(); |
93 | try { |
94 | for (File f : dependees) { canonFiles.add(f.getCanonicalFile()); } |
95 | for (File f : inputs) { canonFiles.add(f.getCanonicalFile()); } |
96 | } catch (IOException ex) { |
97 | logger.println(ex.toString()); |
98 | return false; |
99 | } |
100 | final MessageQueue mq = new SimpleMessageQueue(); |
101 | |
102 | UriFetcher fetcher = new UriFetcher() { |
103 | public FetchedData fetch(ExternalReference ref, String mimeType) |
104 | throws UriFetchException { |
105 | URI uri = ref.getUri(); |
106 | uri = ref.getReferencePosition().source().getUri().resolve(uri); |
107 | InputSource is = new InputSource(uri); |
108 | |
109 | try { |
110 | if (!canonFiles.contains(new File(uri).getCanonicalFile())) { |
111 | throw new UriFetchException(ref, mimeType); |
112 | } |
113 | } catch (IllegalArgumentException ex) { |
114 | throw new UriFetchException(ref, mimeType, ex); |
115 | } catch (IOException ex) { |
116 | throw new UriFetchException(ref, mimeType, ex); |
117 | } |
118 | |
119 | try { |
120 | String content = getSourceContent(is); |
121 | if (content == null) { |
122 | throw new UriFetchException(ref, mimeType); |
123 | } |
124 | return FetchedData.fromCharProducer( |
125 | CharProducer.Factory.fromString(content, is), |
126 | mimeType, Charsets.UTF_8.name()); |
127 | } catch (IOException ex) { |
128 | throw new UriFetchException(ref, mimeType, ex); |
129 | } |
130 | } |
131 | }; |
132 | |
133 | UriPolicy policy = new UriPolicy() { |
134 | public String rewriteUri( |
135 | ExternalReference u, UriEffect effect, LoaderType loader, |
136 | Map<String, ?> hints) { |
137 | // TODO(ihab.awad): Need to pass in the URI rewriter from the build |
138 | // file somehow (as a Cajita program?). The below is a stub. |
139 | return URI.create( |
140 | "http://example.com/" |
141 | + "?effect=" + effect + "&loader=" + loader |
142 | + "&uri=" + UriUtil.encode("" + u.getUri())) |
143 | .toString(); |
144 | } |
145 | }; |
146 | |
147 | MessageContext mc = new MessageContext(); |
148 | |
149 | // Set up the cajoler |
150 | String language = (String) options.get("language"); |
151 | boolean passed = true; |
152 | ParseTreeNode outputJs; |
153 | Node outputHtml; |
154 | if ("caja".equals(language)) { |
155 | PluginCompiler compiler = new PluginCompiler( |
156 | BuildInfo.getInstance(), new PluginMeta(fetcher, policy), mq); |
157 | compiler.setMessageContext(mc); |
158 | if (Boolean.TRUE.equals(options.get("debug"))) { |
159 | compiler.setGoals(compiler.getGoals() |
160 | .without(PipelineMaker.ONE_CAJOLED_MODULE) |
161 | .with(PipelineMaker.ONE_CAJOLED_MODULE_DEBUG)); |
162 | } |
163 | if (Boolean.TRUE.equals(options.get("onlyJsEmitted"))) { |
164 | compiler.setGoals( |
165 | compiler.getGoals().without(PipelineMaker.HTML_SAFE_STATIC)); |
166 | } |
167 | |
168 | // Parse inputs |
169 | for (File f : inputs) { |
170 | try { |
171 | URI fileUri = f.getCanonicalFile().toURI(); |
172 | ParseTreeNode parsedInput = parseInput(new InputSource(fileUri), mq); |
173 | if (parsedInput == null) { |
174 | passed = false; |
175 | } else { |
176 | compiler.addInput(parsedInput, fileUri); |
177 | } |
178 | } catch (IOException ex) { |
179 | logger.println("Failed to read " + f); |
180 | passed = false; |
181 | } |
182 | } |
183 | |
184 | // Cajole |
185 | passed = passed && compiler.run(); |
186 | |
187 | outputJs = passed ? compiler.getJavascript() : null; |
188 | outputHtml = passed ? compiler.getStaticHtml() : null; |
189 | } else if ("javascript".equals(language)) { |
190 | passed = true; |
191 | JsOptimizer optimizer = new JsOptimizer(mq); |
192 | for (File f : inputs) { |
193 | try { |
194 | if (f.getName().endsWith(".env.json")) { |
195 | loadEnvJsonFile(f, optimizer, mq); |
196 | } else { |
197 | ParseTreeNode parsedInput = parseInput( |
198 | new InputSource(f.getCanonicalFile().toURI()), mq); |
199 | if (parsedInput != null) { |
200 | optimizer.addInput((Statement) parsedInput); |
201 | } |
202 | } |
203 | } catch (IOException ex) { |
204 | logger.println("Failed to read " + f); |
205 | passed = false; |
206 | } |
207 | } |
208 | outputJs = optimizer.optimize(); |
209 | outputHtml = null; |
210 | } else { |
211 | throw new RuntimeException("Unrecognized language: " + language); |
212 | } |
213 | passed = passed && !hasErrors(mq); |
214 | |
215 | // From the ignore attribute to the <transform> element. |
216 | Set<?> toIgnore = (Set<?>) options.get("toIgnore"); |
217 | if (toIgnore == null) { toIgnore = Collections.emptySet(); } |
218 | |
219 | // Log messages |
220 | SnippetProducer snippetProducer = new SnippetProducer(originalSources, mc); |
221 | for (Message msg : mq.getMessages()) { |
222 | if (passed && MessageLevel.LOG.compareTo(msg.getMessageLevel()) >= 0) { |
223 | continue; |
224 | } |
225 | String snippet = snippetProducer.getSnippet(msg); |
226 | if (!"".equals(snippet)) { snippet = "\n" + snippet; } |
227 | if (!passed || !toIgnore.contains(msg.getMessageType().name())) { |
228 | logger.println( |
229 | msg.getMessageLevel() + " : " + msg.format(mc) + snippet); |
230 | } |
231 | } |
232 | |
233 | // Write the output |
234 | if (passed) { |
235 | // Write out as HTML if the output file has the right extension. |
236 | boolean asXml = output.getName().endsWith(".xhtml"); |
237 | boolean emitMarkup = asXml || output.getName().endsWith(".html"); |
238 | |
239 | StringBuilder jsOut = new StringBuilder(); |
240 | TokenConsumer renderer; |
241 | String rendererType = (String) options.get("renderer"); |
242 | if ("pretty".equals(rendererType)) { |
243 | renderer = new JsPrettyPrinter(new Concatenator(jsOut)); |
244 | } else if ("minify".equals(rendererType)) { |
245 | renderer = new JsMinimalPrinter(new Concatenator(jsOut)); |
246 | } else { |
247 | throw new RuntimeException("Unrecognized renderer " + rendererType); |
248 | } |
249 | RenderContext rc = new RenderContext(renderer).withEmbeddable(emitMarkup); |
250 | outputJs.render(rc); |
251 | rc.getOut().noMoreTokens(); |
252 | |
253 | String htmlOut = ""; |
254 | if (outputHtml != null) { |
255 | htmlOut = Nodes.render( |
256 | outputHtml, asXml ? MarkupRenderMode.XML : MarkupRenderMode.HTML); |
257 | } |
258 | |
259 | String translatedCode; |
260 | if (emitMarkup) { |
261 | Document doc = DomParser.makeDocument(null, null); |
262 | String ns = Namespaces.HTML_NAMESPACE_URI; |
263 | Element script = doc.createElementNS(ns, "script"); |
264 | script.setAttributeNS(ns, "type", "text/javascript"); |
265 | script.appendChild(doc.createCDATASection(jsOut.toString())); |
266 | translatedCode = htmlOut + Nodes.render( |
267 | script, asXml ? MarkupRenderMode.XML : MarkupRenderMode.HTML); |
268 | } else { |
269 | if (!"".equals(htmlOut)) { |
270 | throw new RuntimeException("Can't emit HTML to " + output); |
271 | } |
272 | translatedCode = jsOut.toString(); |
273 | } |
274 | |
275 | try { |
276 | Writer w = new OutputStreamWriter(new FileOutputStream(output)); |
277 | try { |
278 | w.write(translatedCode); |
279 | } finally { |
280 | w.close(); |
281 | } |
282 | } catch (IOException ex) { |
283 | logger.println("Failed to write " + output); |
284 | return false; |
285 | } |
286 | } |
287 | return passed; |
288 | } |
289 | |
290 | private String getSourceContent(InputSource is) throws IOException { |
291 | String content = originalSources.get(is); |
292 | if (content == null) { |
293 | File f = new File(is.getUri()); |
294 | // Read it in and stuff it back in the map so we can generate |
295 | // snippets. |
296 | Reader in = new InputStreamReader(new FileInputStream(f), Charsets.UTF_8); |
297 | try { |
298 | char[] buf = new char[4096]; |
299 | StringBuilder sb = new StringBuilder(); |
300 | for (int n; (n = in.read(buf, 0, buf.length)) > 0;) { |
301 | sb.append(buf, 0, n); |
302 | } |
303 | content = sb.toString(); |
304 | } finally { |
305 | in.close(); |
306 | } |
307 | originalSources.put(is, content); |
308 | } |
309 | return content; |
310 | } |
311 | |
312 | private ParseTreeNode parseInput(InputSource is, MessageQueue mq) |
313 | throws IOException { |
314 | CharProducer cp = CharProducer.Factory.fromString(getSourceContent(is), is); |
315 | try { |
316 | return PluginCompilerMain.parseInput(is, cp, mq); |
317 | } catch (ParseException ex) { |
318 | ex.toMessageQueue(mq); |
319 | return null; |
320 | } |
321 | } |
322 | |
323 | /** |
324 | * Minifies inputs to output writing any messages to logger, returning true |
325 | * iff the task passes. |
326 | */ |
327 | public boolean minify( |
328 | PrintWriter logger, List<File> dependees, List<File> inputs, File output, |
329 | Map<String, Object> options) { |
330 | try { |
331 | List<Pair<InputSource, File>> inputSources = Lists.newArrayList(); |
332 | for (File f : inputs) { |
333 | inputSources.add( |
334 | Pair.pair(new InputSource(f.getAbsoluteFile().toURI()), f)); |
335 | } |
336 | Writer outputWriter = new OutputStreamWriter( |
337 | new FileOutputStream(output), Charsets.UTF_8); |
338 | try { |
339 | return Minify.minify(inputSources, outputWriter, logger); |
340 | } finally { |
341 | outputWriter.close(); |
342 | } |
343 | } catch (IOException ex) { |
344 | logger.println("Minifying failed: " + ex); |
345 | return false; |
346 | } |
347 | } |
348 | |
349 | /** |
350 | * Applies the innocent code transformer to inputs. Writes |
351 | * any messages to logger and returns true iff the task passes. |
352 | */ |
353 | public boolean transfInnocent( |
354 | PrintWriter logger, List<File> dependees, List<File> inputs, File output, |
355 | Map<String, Object> options) { |
356 | try { |
357 | boolean ret; |
358 | Writer outputWriter = new OutputStreamWriter( |
359 | new FileOutputStream(output), Charsets.UTF_8); |
360 | for (File f : inputs) { |
361 | Pair<InputSource, File> inputSource = |
362 | Pair.pair(new InputSource(f.getAbsoluteFile().toURI()), f); |
363 | ret = Innocent.transfInnocent(inputSource, outputWriter, logger); |
364 | if (!ret) { |
365 | outputWriter.close(); |
366 | return false; |
367 | } |
368 | } |
369 | outputWriter.close(); |
370 | return true; |
371 | } catch (IOException ex) { |
372 | logger.println("Innocent transform failed: " + ex); |
373 | return false; |
374 | } |
375 | } |
376 | |
377 | private static void loadEnvJsonFile(File f, JsOptimizer op, MessageQueue mq) { |
378 | CharProducer cp; |
379 | try { |
380 | cp = read(f); |
381 | } catch (IOException ex) { |
382 | mq.addMessage( |
383 | MessageType.IO_ERROR, MessagePart.Factory.valueOf(ex.toString())); |
384 | return; |
385 | } |
386 | ObjectConstructor envJson; |
387 | try { |
388 | Parser p = parser(cp, mq); |
389 | Expression e = p.parseExpression(true); // TODO(mikesamuel): limit to JSON |
390 | p.getTokenQueue().expectEmpty(); |
391 | if (!(e instanceof ObjectConstructor)) { |
392 | mq.addMessage( |
393 | MessageType.IO_ERROR, |
394 | MessagePart.Factory.valueOf("Invalid JSON in " + f)); |
395 | return; |
396 | } |
397 | envJson = (ObjectConstructor) e; |
398 | } catch (ParseException ex) { |
399 | ex.toMessageQueue(mq); |
400 | return; |
401 | } |
402 | op.setEnvJson(envJson); |
403 | } |
404 | |
405 | private static CharProducer read(File f) throws IOException { |
406 | InputSource is = new InputSource(f.toURI()); |
407 | return CharProducer.Factory.create( |
408 | new InputStreamReader(new FileInputStream(f), Charsets.UTF_8), is); |
409 | } |
410 | |
411 | private static Parser parser(CharProducer cp, MessageQueue errs) { |
412 | JsLexer lexer = new JsLexer(cp); |
413 | JsTokenQueue tq = new JsTokenQueue(lexer, cp.getCurrentPosition().source()); |
414 | return new Parser(tq, errs); |
415 | } |
416 | |
417 | private static boolean hasErrors(MessageQueue mq) { |
418 | for (Message msg : mq.getMessages()) { |
419 | if (MessageLevel.ERROR.compareTo(msg.getMessageLevel()) <= 0) { |
420 | return true; |
421 | } |
422 | } |
423 | return false; |
424 | } |
425 | } |