blob: d476894b07cc78c583a89397804bb2d7e5b67b18 [file] [log] [blame]
// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Transfomer that finalizes an html file for deployment:
/// - Extracts inline js scripts in csp mode.
/// - Inlines css files into the document.
/// - Validates polymer-element templates.
library polymer.src.build.html_finalizer;
import 'dart:async';
import 'dart:collection' show LinkedHashSet;
import 'package:barback/barback.dart';
import 'package:code_transformers/assets.dart';
import 'package:code_transformers/messages/build_logger.dart';
import 'package:path/path.dart' as path;
import 'package:html/dom.dart'
show Document, DocumentFragment, Element, Node;
import 'package:html/dom_parsing.dart' show TreeVisitor;
import 'package:source_span/source_span.dart';
import 'common.dart';
import 'messages.dart';
/// Inlines css files and extracts inline js scripts into files if in csp mode.
// TODO(jakemac): Move to a different package. Will need to break out the
// binding-specific logic when this happens (add it to the linter?).
class _HtmlFinalizer extends PolymerTransformer {
final TransformOptions options;
final Transform transform;
final BuildLogger logger;
final AssetId docId;
final seen = new Set<AssetId>();
final scriptIds = new LinkedHashSet<AssetId>();
final inlinedStylesheetIds = new Set<AssetId>();
final extractedFiles = new Set<AssetId>();
/// The number of extracted inline Dart scripts. Used as a counter to give
/// unique-ish filenames.
int inlineScriptCounter = 0;
_HtmlFinalizer(TransformOptions options, Transform transform)
: options = options,
transform = transform,
logger = new BuildLogger(transform,
convertErrorsToWarnings: !options.releaseMode,
detailsUri: 'http://goo.gl/5HPeuP'),
docId = transform.primaryInput.id;
Future apply() {
seen.add(docId);
Document document;
bool changed = false;
return readPrimaryAsHtml(transform, logger).then((doc) {
document = doc;
new _UrlAttributeValidator(docId, logger).visit(document);
changed = _extractScripts(document) || changed;
return _inlineCss(document);
}).then((cssInlined) {
changed = changed || cssInlined;
var output = transform.primaryInput;
if (changed) output = new Asset.fromString(docId, document.outerHtml);
transform.addOutput(output);
// Write out the logs collected by our [BuildLogger].
if (options.injectBuildLogsInOutput) {
return logger.writeOutput();
}
});
}
/// Inlines any css files found into document. Returns a [bool] indicating
/// whether or not the document was modified.
Future<bool> _inlineCss(Document document) {
bool changed = false;
// Note: we need to preserve the import order in the generated output.
var tags = document.querySelectorAll('link[rel="stylesheet"]');
return Future.forEach(tags, (Element tag) {
var href = tag.attributes['href'];
var id = uriToAssetId(docId, href, logger, tag.sourceSpan,
errorOnAbsolute: false);
if (id == null) return null;
if (!options.shouldInlineStylesheet(id)) return null;
changed = true;
if (inlinedStylesheetIds.contains(id) &&
!options.stylesheetInliningIsOverridden(id)) {
logger.warning(CSS_FILE_INLINED_MULTIPLE_TIMES.create({'url': id.path}),
span: tag.sourceSpan);
}
inlinedStylesheetIds.add(id);
return _inlineStylesheet(id, tag);
}).then((_) => changed);
}
/// Inlines a single css file by replacing [link] with an inline style tag.
Future _inlineStylesheet(AssetId id, Element link) {
return transform.readInputAsString(id).catchError((error) {
// TODO(jakemac): Move this warning to the linter once we can make it run
// always (see http://dartbug.com/17199). Then hide this error and replace
// with a comment pointing to the linter error (so we don't double warn).
logger.warning(INLINE_STYLE_FAIL.create({'error': error}),
span: link.sourceSpan);
}).then((css) {
if (css == null) return null;
css = new _UrlNormalizer(transform, id, logger).visitCss(css);
var styleElement = new Element.tag('style')..text = css;
// Copy over the extra attributes from the link tag to the style tag.
// This adds support for no-shim, shim-shadowdom, etc.
link.attributes.forEach((key, value) {
if (!IGNORED_LINKED_STYLE_ATTRS.contains(key)) {
styleElement.attributes[key] = value;
}
});
link.replaceWith(styleElement);
});
}
/// Splits inline js scripts into their own files in csp mode.
bool _extractScripts(Document doc) {
if (!options.contentSecurityPolicy) return false;
bool changed = false;
for (var script in doc.querySelectorAll('script')) {
var src = script.attributes['src'];
if (src != null) continue;
var type = script.attributes['type'];
if (type == TYPE_DART) continue;
var extension = 'js';
final filename = path.url.basename(docId.path);
final count = inlineScriptCounter++;
var code = script.text;
// TODO(sigmund): ensure this path is unique (dartbug.com/12618).
script.attributes['src'] = src = '$filename.$count.$extension';
script.text = '';
changed = true;
var newId = docId.addExtension('.$count.$extension');
extractedFiles.add(newId);
transform.addOutput(new Asset.fromString(newId, code));
}
return changed;
}
}
/// Finalizes a single html document for deployment.
class HtmlFinalizer extends Transformer {
final TransformOptions options;
HtmlFinalizer(this.options);
/// Only run on entry point .html files.
bool isPrimary(AssetId id) => options.isHtmlEntryPoint(id);
Future apply(Transform transform) =>
new _HtmlFinalizer(options, transform).apply();
}
const TYPE_DART = 'application/dart';
const TYPE_JS = 'text/javascript';
/// Internally adjusts urls in the html that we are about to inline.
class _UrlNormalizer {
final Transform transform;
/// Asset where the original content (and original url) was found.
final AssetId sourceId;
/// Path to the top level folder relative to the transform primaryInput.
/// This should just be some arbitrary # of ../'s.
final String topLevelPath;
/// Whether or not the normalizer has changed something in the tree.
bool changed = false;
final BuildLogger logger;
_UrlNormalizer(transform, this.sourceId, this.logger)
: transform = transform,
topLevelPath = '../' *
(path.url.split(transform.primaryInput.id.path).length - 2);
static final _URL = new RegExp(r'url\(([^)]*)\)', multiLine: true);
static final _QUOTE = new RegExp('["\']', multiLine: true);
/// Visit the CSS text and replace any relative URLs so we can inline it.
// Ported from:
// https://github.com/Polymer/vulcanize/blob/c14f63696797cda18dc3d372b78aa3378acc691f/lib/vulcan.js#L149
// TODO(jmesserly): use csslib here instead? Parsing with RegEx is sadness.
// Maybe it's reliable enough for finding URLs in CSS? I'm not sure.
String visitCss(String cssText) {
var url = spanUrlFor(sourceId, transform, logger);
var src = new SourceFile(cssText, url: url);
return cssText.replaceAllMapped(_URL, (match) {
// Extract the URL, without any surrounding quotes.
var span = src.span(match.start, match.end);
var href = match[1].replaceAll(_QUOTE, '');
href = _newUrl(href, span);
return 'url($href)';
});
}
String _newUrl(String href, SourceSpan span) {
var uri = Uri.parse(href);
if (uri.isAbsolute) return href;
if (!uri.scheme.isEmpty) return href;
if (!uri.host.isEmpty) return href;
if (uri.path.isEmpty) return href; // Implies standalone ? or # in URI.
if (path.isAbsolute(href)) return href;
var id = uriToAssetId(sourceId, href, logger, span);
if (id == null) return href;
var primaryId = transform.primaryInput.id;
if (id.path.startsWith('lib/')) {
return '${topLevelPath}packages/${id.package}/${id.path.substring(4)}';
}
if (id.path.startsWith('asset/')) {
return '${topLevelPath}assets/${id.package}/${id.path.substring(6)}';
}
if (primaryId.package != id.package) {
// Technically we shouldn't get there
logger.error(INTERNAL_ERROR_DONT_KNOW_HOW_TO_IMPORT
.create({'target': id, 'source': primaryId, 'extra': ''}),
span: span);
return href;
}
var builder = path.url;
return builder.relative(builder.join('/', id.path),
from: builder.join('/', builder.dirname(primaryId.path)));
}
}
/// Validates url-like attributes and throws warnings as appropriate.
/// TODO(jakemac): Move to the linter.
class _UrlAttributeValidator extends TreeVisitor {
/// Asset where the original content (and original url) was found.
final AssetId sourceId;
final BuildLogger logger;
_UrlAttributeValidator(this.sourceId, this.logger);
visit(Node node) {
return super.visit(node);
}
visitElement(Element node) {
// TODO(jakemac): Support custom elements that extend html elements which
// have url-like attributes. This probably means keeping a list of which
// html elements support each url-like attribute.
if (!isCustomTagName(node.localName)) {
node.attributes.forEach((name, value) {
if (_urlAttributes.contains(name)) {
if (!name.startsWith('_') && value.contains(_BINDING_REGEX)) {
logger.warning(USE_UNDERSCORE_PREFIX.create({'name': name}),
span: node.sourceSpan, asset: sourceId);
} else if (name.startsWith('_') && !value.contains(_BINDING_REGEX)) {
logger.warning(
DONT_USE_UNDERSCORE_PREFIX.create({'name': name.substring(1)}),
span: node.sourceSpan, asset: sourceId);
}
}
});
}
return super.visitElement(node);
}
}
/// HTML attributes that expect a URL value.
/// <http://dev.w3.org/html5/spec/section-index.html#attributes-1>
///
/// Every one of these attributes is a URL in every context where it is used in
/// the DOM. The comments show every DOM element where an attribute can be used.
///
/// The _* version of each attribute is also supported, see http://goo.gl/5av8cU
const _urlAttributes = const [
// in form
'action',
'_action',
// in body
'background',
'_background',
// in blockquote, del, ins, q
'cite',
'_cite',
// in object
'data',
'_data',
// in button, input
'formaction',
'_formaction',
// in a, area, link, base, command
'href',
'_href',
// in command
'icon',
'_icon',
// in html
'manifest',
'_manifest',
// in video
'poster',
'_poster',
// in audio, embed, iframe, img, input, script, source, track, video
'src',
'_src',
];
/// When inlining <link rel="stylesheet"> tags copy over all attributes to the
/// style tag except these ones.
const IGNORED_LINKED_STYLE_ATTRS = const [
'charset',
'href',
'href-lang',
'rel',
'rev'
];
/// Global RegExp objects.
final _BINDING_REGEX = new RegExp(r'(({{.*}})|(\[\[.*\]\]))');