OLD | NEW |
| (Empty) |
1 // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file | |
2 // for details. All rights reserved. Use of this source code is governed by a | |
3 // BSD-style license that can be found in the LICENSE file. | |
4 | |
5 /// Transfomer that finalizes an html file for deployment: | |
6 /// - Extracts inline js scripts in csp mode. | |
7 /// - Inlines css files into the document. | |
8 /// - Validates polymer-element templates. | |
9 library polymer.src.build.html_finalizer; | |
10 | |
11 import 'dart:async'; | |
12 import 'dart:collection' show LinkedHashSet; | |
13 | |
14 import 'package:barback/barback.dart'; | |
15 import 'package:code_transformers/assets.dart'; | |
16 import 'package:code_transformers/messages/build_logger.dart'; | |
17 import 'package:path/path.dart' as path; | |
18 import 'package:html/dom.dart' | |
19 show Document, DocumentFragment, Element, Node; | |
20 import 'package:html/dom_parsing.dart' show TreeVisitor; | |
21 import 'package:source_span/source_span.dart'; | |
22 | |
23 import 'common.dart'; | |
24 import 'messages.dart'; | |
25 | |
26 /// Inlines css files and extracts inline js scripts into files if in csp mode. | |
27 // TODO(jakemac): Move to a different package. Will need to break out the | |
28 // binding-specific logic when this happens (add it to the linter?). | |
29 class _HtmlFinalizer extends PolymerTransformer { | |
30 final TransformOptions options; | |
31 final Transform transform; | |
32 final BuildLogger logger; | |
33 final AssetId docId; | |
34 final seen = new Set<AssetId>(); | |
35 final scriptIds = new LinkedHashSet<AssetId>(); | |
36 final inlinedStylesheetIds = new Set<AssetId>(); | |
37 final extractedFiles = new Set<AssetId>(); | |
38 | |
39 /// The number of extracted inline Dart scripts. Used as a counter to give | |
40 /// unique-ish filenames. | |
41 int inlineScriptCounter = 0; | |
42 | |
43 _HtmlFinalizer(TransformOptions options, Transform transform) | |
44 : options = options, | |
45 transform = transform, | |
46 logger = new BuildLogger(transform, | |
47 convertErrorsToWarnings: !options.releaseMode, | |
48 detailsUri: 'http://goo.gl/5HPeuP'), | |
49 docId = transform.primaryInput.id; | |
50 | |
51 Future apply() { | |
52 seen.add(docId); | |
53 | |
54 Document document; | |
55 bool changed = false; | |
56 | |
57 return readPrimaryAsHtml(transform, logger).then((doc) { | |
58 document = doc; | |
59 new _UrlAttributeValidator(docId, logger).visit(document); | |
60 | |
61 changed = _extractScripts(document) || changed; | |
62 | |
63 return _inlineCss(document); | |
64 }).then((cssInlined) { | |
65 changed = changed || cssInlined; | |
66 | |
67 var output = transform.primaryInput; | |
68 if (changed) output = new Asset.fromString(docId, document.outerHtml); | |
69 transform.addOutput(output); | |
70 | |
71 // Write out the logs collected by our [BuildLogger]. | |
72 if (options.injectBuildLogsInOutput) { | |
73 return logger.writeOutput(); | |
74 } | |
75 }); | |
76 } | |
77 | |
78 /// Inlines any css files found into document. Returns a [bool] indicating | |
79 /// whether or not the document was modified. | |
80 Future<bool> _inlineCss(Document document) { | |
81 bool changed = false; | |
82 | |
83 // Note: we need to preserve the import order in the generated output. | |
84 var tags = document.querySelectorAll('link[rel="stylesheet"]'); | |
85 return Future.forEach(tags, (Element tag) { | |
86 var href = tag.attributes['href']; | |
87 var id = uriToAssetId(docId, href, logger, tag.sourceSpan, | |
88 errorOnAbsolute: false); | |
89 if (id == null) return null; | |
90 if (!options.shouldInlineStylesheet(id)) return null; | |
91 | |
92 changed = true; | |
93 if (inlinedStylesheetIds.contains(id) && | |
94 !options.stylesheetInliningIsOverridden(id)) { | |
95 logger.warning(CSS_FILE_INLINED_MULTIPLE_TIMES.create({'url': id.path}), | |
96 span: tag.sourceSpan); | |
97 } | |
98 inlinedStylesheetIds.add(id); | |
99 return _inlineStylesheet(id, tag); | |
100 }).then((_) => changed); | |
101 } | |
102 | |
103 /// Inlines a single css file by replacing [link] with an inline style tag. | |
104 Future _inlineStylesheet(AssetId id, Element link) { | |
105 return transform.readInputAsString(id).catchError((error) { | |
106 // TODO(jakemac): Move this warning to the linter once we can make it run | |
107 // always (see http://dartbug.com/17199). Then hide this error and replace | |
108 // with a comment pointing to the linter error (so we don't double warn). | |
109 logger.warning(INLINE_STYLE_FAIL.create({'error': error}), | |
110 span: link.sourceSpan); | |
111 }).then((css) { | |
112 if (css == null) return null; | |
113 css = new _UrlNormalizer(transform, id, logger).visitCss(css); | |
114 var styleElement = new Element.tag('style')..text = css; | |
115 // Copy over the extra attributes from the link tag to the style tag. | |
116 // This adds support for no-shim, shim-shadowdom, etc. | |
117 link.attributes.forEach((key, value) { | |
118 if (!IGNORED_LINKED_STYLE_ATTRS.contains(key)) { | |
119 styleElement.attributes[key] = value; | |
120 } | |
121 }); | |
122 link.replaceWith(styleElement); | |
123 }); | |
124 } | |
125 | |
126 /// Splits inline js scripts into their own files in csp mode. | |
127 bool _extractScripts(Document doc) { | |
128 if (!options.contentSecurityPolicy) return false; | |
129 | |
130 bool changed = false; | |
131 for (var script in doc.querySelectorAll('script')) { | |
132 var src = script.attributes['src']; | |
133 if (src != null) continue; | |
134 | |
135 var type = script.attributes['type']; | |
136 if (type == TYPE_DART) continue; | |
137 | |
138 var extension = 'js'; | |
139 final filename = path.url.basename(docId.path); | |
140 final count = inlineScriptCounter++; | |
141 var code = script.text; | |
142 // TODO(sigmund): ensure this path is unique (dartbug.com/12618). | |
143 script.attributes['src'] = src = '$filename.$count.$extension'; | |
144 script.text = ''; | |
145 changed = true; | |
146 | |
147 var newId = docId.addExtension('.$count.$extension'); | |
148 extractedFiles.add(newId); | |
149 transform.addOutput(new Asset.fromString(newId, code)); | |
150 } | |
151 return changed; | |
152 } | |
153 } | |
154 | |
155 /// Finalizes a single html document for deployment. | |
156 class HtmlFinalizer extends Transformer { | |
157 final TransformOptions options; | |
158 | |
159 HtmlFinalizer(this.options); | |
160 | |
161 /// Only run on entry point .html files. | |
162 bool isPrimary(AssetId id) => options.isHtmlEntryPoint(id); | |
163 | |
164 Future apply(Transform transform) => | |
165 new _HtmlFinalizer(options, transform).apply(); | |
166 } | |
167 | |
168 const TYPE_DART = 'application/dart'; | |
169 const TYPE_JS = 'text/javascript'; | |
170 | |
171 /// Internally adjusts urls in the html that we are about to inline. | |
172 class _UrlNormalizer { | |
173 final Transform transform; | |
174 | |
175 /// Asset where the original content (and original url) was found. | |
176 final AssetId sourceId; | |
177 | |
178 /// Path to the top level folder relative to the transform primaryInput. | |
179 /// This should just be some arbitrary # of ../'s. | |
180 final String topLevelPath; | |
181 | |
182 /// Whether or not the normalizer has changed something in the tree. | |
183 bool changed = false; | |
184 | |
185 final BuildLogger logger; | |
186 | |
187 _UrlNormalizer(transform, this.sourceId, this.logger) | |
188 : transform = transform, | |
189 topLevelPath = '../' * | |
190 (path.url.split(transform.primaryInput.id.path).length - 2); | |
191 | |
192 static final _URL = new RegExp(r'url\(([^)]*)\)', multiLine: true); | |
193 static final _QUOTE = new RegExp('["\']', multiLine: true); | |
194 | |
195 /// Visit the CSS text and replace any relative URLs so we can inline it. | |
196 // Ported from: | |
197 // https://github.com/Polymer/vulcanize/blob/c14f63696797cda18dc3d372b78aa3378
acc691f/lib/vulcan.js#L149 | |
198 // TODO(jmesserly): use csslib here instead? Parsing with RegEx is sadness. | |
199 // Maybe it's reliable enough for finding URLs in CSS? I'm not sure. | |
200 String visitCss(String cssText) { | |
201 var url = spanUrlFor(sourceId, transform, logger); | |
202 var src = new SourceFile(cssText, url: url); | |
203 return cssText.replaceAllMapped(_URL, (match) { | |
204 // Extract the URL, without any surrounding quotes. | |
205 var span = src.span(match.start, match.end); | |
206 var href = match[1].replaceAll(_QUOTE, ''); | |
207 href = _newUrl(href, span); | |
208 return 'url($href)'; | |
209 }); | |
210 } | |
211 | |
212 String _newUrl(String href, SourceSpan span) { | |
213 var uri = Uri.parse(href); | |
214 if (uri.isAbsolute) return href; | |
215 if (!uri.scheme.isEmpty) return href; | |
216 if (!uri.host.isEmpty) return href; | |
217 if (uri.path.isEmpty) return href; // Implies standalone ? or # in URI. | |
218 if (path.isAbsolute(href)) return href; | |
219 | |
220 var id = uriToAssetId(sourceId, href, logger, span); | |
221 if (id == null) return href; | |
222 var primaryId = transform.primaryInput.id; | |
223 | |
224 if (id.path.startsWith('lib/')) { | |
225 return '${topLevelPath}packages/${id.package}/${id.path.substring(4)}'; | |
226 } | |
227 | |
228 if (id.path.startsWith('asset/')) { | |
229 return '${topLevelPath}assets/${id.package}/${id.path.substring(6)}'; | |
230 } | |
231 | |
232 if (primaryId.package != id.package) { | |
233 // Technically we shouldn't get there | |
234 logger.error(INTERNAL_ERROR_DONT_KNOW_HOW_TO_IMPORT | |
235 .create({'target': id, 'source': primaryId, 'extra': ''}), | |
236 span: span); | |
237 return href; | |
238 } | |
239 | |
240 var builder = path.url; | |
241 return builder.relative(builder.join('/', id.path), | |
242 from: builder.join('/', builder.dirname(primaryId.path))); | |
243 } | |
244 } | |
245 | |
246 /// Validates url-like attributes and throws warnings as appropriate. | |
247 /// TODO(jakemac): Move to the linter. | |
248 class _UrlAttributeValidator extends TreeVisitor { | |
249 /// Asset where the original content (and original url) was found. | |
250 final AssetId sourceId; | |
251 | |
252 final BuildLogger logger; | |
253 | |
254 _UrlAttributeValidator(this.sourceId, this.logger); | |
255 | |
256 visit(Node node) { | |
257 return super.visit(node); | |
258 } | |
259 | |
260 visitElement(Element node) { | |
261 // TODO(jakemac): Support custom elements that extend html elements which | |
262 // have url-like attributes. This probably means keeping a list of which | |
263 // html elements support each url-like attribute. | |
264 if (!isCustomTagName(node.localName)) { | |
265 node.attributes.forEach((name, value) { | |
266 if (_urlAttributes.contains(name)) { | |
267 if (!name.startsWith('_') && value.contains(_BINDING_REGEX)) { | |
268 logger.warning(USE_UNDERSCORE_PREFIX.create({'name': name}), | |
269 span: node.sourceSpan, asset: sourceId); | |
270 } else if (name.startsWith('_') && !value.contains(_BINDING_REGEX)) { | |
271 logger.warning( | |
272 DONT_USE_UNDERSCORE_PREFIX.create({'name': name.substring(1)}), | |
273 span: node.sourceSpan, asset: sourceId); | |
274 } | |
275 } | |
276 }); | |
277 } | |
278 return super.visitElement(node); | |
279 } | |
280 } | |
281 | |
282 /// HTML attributes that expect a URL value. | |
283 /// <http://dev.w3.org/html5/spec/section-index.html#attributes-1> | |
284 /// | |
285 /// Every one of these attributes is a URL in every context where it is used in | |
286 /// the DOM. The comments show every DOM element where an attribute can be used. | |
287 /// | |
288 /// The _* version of each attribute is also supported, see http://goo.gl/5av8cU | |
289 const _urlAttributes = const [ | |
290 // in form | |
291 'action', | |
292 '_action', | |
293 // in body | |
294 'background', | |
295 '_background', | |
296 // in blockquote, del, ins, q | |
297 'cite', | |
298 '_cite', | |
299 // in object | |
300 'data', | |
301 '_data', | |
302 // in button, input | |
303 'formaction', | |
304 '_formaction', | |
305 // in a, area, link, base, command | |
306 'href', | |
307 '_href', | |
308 // in command | |
309 'icon', | |
310 '_icon', | |
311 // in html | |
312 'manifest', | |
313 '_manifest', | |
314 // in video | |
315 'poster', | |
316 '_poster', | |
317 // in audio, embed, iframe, img, input, script, source, track, video | |
318 'src', | |
319 '_src', | |
320 ]; | |
321 | |
322 /// When inlining <link rel="stylesheet"> tags copy over all attributes to the | |
323 /// style tag except these ones. | |
324 const IGNORED_LINKED_STYLE_ATTRS = const [ | |
325 'charset', | |
326 'href', | |
327 'href-lang', | |
328 'rel', | |
329 'rev' | |
330 ]; | |
331 | |
332 /// Global RegExp objects. | |
333 final _BINDING_REGEX = new RegExp(r'(({{.*}})|(\[\[.*\]\]))'); | |
OLD | NEW |