blob: 1f562a7459202f1e2bcf891a30f6b4ed83e0bae6 [file] [log] [blame]
// Copyright (c) 2015, 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.
import 'dart:async';
import 'dart:collection' show LinkedHashMap;
import 'package:barback/barback.dart';
import 'package:code_transformers/assets.dart';
import 'package:code_transformers/messages/build_logger.dart';
import 'package:html/dom.dart';
import 'package:html/dom_parsing.dart' show TreeVisitor;
import 'package:path/path.dart' as path;
import 'package:source_span/source_span.dart';
import 'common.dart';
import 'import_crawler.dart';
import 'messages.dart';
/// Transformer which inlines all html imports found from the entry points. This
/// deletes all dart scripts found during the inlining, so the
/// [ScriptCompactorTransformer] should be ran first if there are any dart files
/// in html imports.
class ImportInlinerTransformer extends Transformer {
final List<String> entryPoints;
final List<String> bindingStartDelimiters;
[this.entryPoints, this.bindingStartDelimiters = const []]);
bool isPrimary(AssetId id) {
if (entryPoints != null) return entryPoints.contains(id.path);
// If no entry point is supplied, then any html file under web/ or test/ is
// an entry point.
return (id.path.startsWith('web/') || id.path.startsWith('test/')) &&
apply(Transform transform) {
var logger = new BuildLogger(transform, convertErrorsToWarnings: true);
return new ImportInliner(transform,, logger,
bindingStartDelimiters: bindingStartDelimiters).run();
/// Helper class which actually does all the inlining of html imports for a
/// single entry point.
class ImportInliner {
// Can be an AggregateTransform or Transform
final transform;
// The primary input to start from.
final AssetId primaryInput;
// The logger to use.
final BuildLogger logger;
// The start delimiters for template bindings, such as '{{' or '[['.
final List<String> bindingStartDelimiters;
ImportInliner(this.transform, this.primaryInput, this.logger,
{this.bindingStartDelimiters: const []});
Future run() {
var crawler = new ImportCrawler(transform, primaryInput, logger);
return crawler.crawlImports().then((imports) {
var primaryDocument = imports[primaryInput].document;
// Normalize urls in the entry point.
var changed = new _UrlNormalizer(
primaryInput, primaryInput, logger, bindingStartDelimiters)
// Inline things if needed, always have at least one (the entry point).
if (imports.length > 1) {
_inlineImports(primaryDocument, imports);
} else if (!changed &&
(import) => import.attributes['type'] != 'css').length ==
0) {
// If there were no url changes and no imports, then we are done.
.where((import) => import.attributes['type'] != 'css')
.forEach((element) => element.remove());
new Asset.fromString(primaryInput, primaryDocument.outerHtml));
void _inlineImports(
Document primaryDocument, LinkedHashMap<AssetId, ImportData> imports) {
// Add a hidden div at the top of the body, this is where we will inline
// all the imports.
var importWrapper = new Element.tag('div')..attributes['hidden'] = '';
var firstElement = primaryDocument.body.firstChild;
if (firstElement != null) {
primaryDocument.body.insertBefore(importWrapper, firstElement);
} else {
// Move all scripts/stylesheets/imports into the wrapper to maintain
// ordering.
_moveHeadToWrapper(primaryDocument, importWrapper);
// Add all the other imports!
imports.forEach((AssetId asset, ImportData data) {
if (asset == primaryInput) return;
var document = data.document;
// Remove all dart script tags.
.forEach((script) => script.remove());
// Normalize urls in attributes and inline css.
new _UrlNormalizer(data.fromId, asset, logger, bindingStartDelimiters)
// Replace the import with its contents by appending the nodes
// immediately before the import one at a time, and then removing the
// import from the document.
var element = data.element;
var parent = element.parent;
.toList(growable: false)
.forEach((child) => parent.insertBefore(child, element));
.toList(growable: false)
.forEach((child) => parent.insertBefore(child, element));
/// To preserve the order of scripts with respect to inlined
/// link rel=import, we move both of those into the body before we do any
/// inlining. We do not start doing this until the first import is found
/// however, as some scripts do need to be ran in the head to work
/// properly (webcomponents.js for instance).
/// Note: we do this for stylesheets as well to preserve ordering with
/// respect to eachother, because stylesheets can be pulled in transitively
/// from imports.
void _moveHeadToWrapper(Document doc, Element wrapper) {
var foundImport = false;
for (var node in doc.head.nodes.toList(growable: false)) {
if (node is! Element) continue;
var tag = node.localName;
var type = node.attributes['type'];
var rel = node.attributes['rel'];
if (tag == 'link' && rel == 'import') foundImport = true;
if (!foundImport) continue;
if (tag == 'style' ||
tag == 'script' &&
(type == null || type == jsType || type == dartType) ||
tag == 'link' && (rel == 'stylesheet' || rel == 'import')) {
// Move the node into the wrapper, where its contents will be placed.
// This wrapper is a hidden div to prevent inlined html from causing a
// FOUC.
/// Internally adjusts urls in the html that we are about to inline.
// TODO(jakemac): Everything from here down is almost an exact copy from the
// polymer package. We should consolidate this logic by either removing it
// completely from polymer or exposing it publicly here and using that in
// polymer.
class _UrlNormalizer extends TreeVisitor {
/// [AssetId] for the main entry point.
final AssetId primaryInput;
/// Asset where the original content (and original url) was found.
final AssetId sourceId;
/// Counter used to ensure that every library name we inject is unique.
int _count = 0;
/// 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;
// The start delimiters for template bindings, such as '{{' or '[['. If these
// are found before the first `/` in a url, then the url will not be
// normalized.
final List<String> bindingStartDelimiters;
final BuildLogger logger;
_UrlNormalizer(AssetId primaryInput, this.sourceId, this.logger,
: primaryInput = primaryInput,
topLevelPath = '../' * (path.url.split(primaryInput.path).length - 2);
bool visit(Node node) {
return changed;
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)) {
node.attributes[name] = _newUrl(value, node.sourceSpan);
changed = value != node.attributes[name];
if (node.localName == 'style') {
node.text = visitCss(node.text);
} else if (node.localName == 'script' &&
node.attributes['type'] == dartType &&
!node.attributes.containsKey('src')) {
changed = true;
return super.visitElement(node);
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:
// 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, primaryInput, logger);
var src = new SourceFile(cssText, url: url);
return cssText.replaceAllMapped(_url, (match) {
changed = true;
// 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) {
// We only want to parse the part of the href leading up to the first
// folder, anything after that is not informative.
var hrefToParse;
var firstFolder = href.indexOf('/');
if (firstFolder == -1) {
hrefToParse = href;
} else if (firstFolder == 0) {
return href;
} else {
// Special case packages and assets urls.
if (href.contains('packages/')) {
var suffix = href.substring(href.indexOf('packages/') + 9);
return '${topLevelPath}packages/$suffix';
} else if (href.contains('assets/')) {
var suffix = href.substring(href.indexOf('assets/') + 7);
return '${topLevelPath}packages/$suffix';
hrefToParse = '${href.substring(0, firstFolder + 1)}';
// If we found a binding before the first `/`, then just return the original
// href, we can't determine anything about it.
if (bindingStartDelimiters.any((d) => hrefToParse.contains(d))) return href;
Uri uri;
// Various template systems introduce invalid characters to uris which would
// be typically replaced at runtime. Parse errors are assumed to be caused
// by this, and we just return the original href in that case.
try {
uri = Uri.parse(hrefToParse);
} catch (e) {
return href;
if (uri.isAbsolute) return href;
if (uri.scheme.isNotEmpty) return href;
if ( return href;
if (uri.path.isEmpty) return href; // Implies standalone ? or # in URI.
if (path.isAbsolute(hrefToParse)) return href;
var id = uriToAssetId(sourceId, hrefToParse, logger, span);
if (id == null) return href;
// Build the new path, placing back any suffixes that we stripped earlier.
var prefix =
(firstFolder == -1) ? id.path : id.path.substring(0, id.path.length);
var suffix = (firstFolder == -1) ? '' : href.substring(firstFolder);
var newPath = '$prefix$suffix';
if (newPath.startsWith('lib/')) {
return '${topLevelPath}packages/${id.package}/${newPath.substring(4)}';
if (newPath.startsWith('asset/')) {
return '${topLevelPath}assets/${id.package}/${newPath.substring(6)}';
if (primaryInput.package != id.package) {
// Technically we shouldn't get there
.create({'target': id, 'source': primaryInput, 'extra': ''}),
span: span);
return href;
var builder = path.url;
return builder.normalize(builder.relative(builder.join('/', newPath),
from: builder.join('/', builder.dirname(primaryInput.path))));
/// Returns true if this is a valid custom element name. See:
/// <>
bool isCustomTagName(String name) {
if (name == null || !name.contains('-')) return false;
return !invalidTagNames.containsKey(name);
/// These names have meaning in SVG or MathML, so they aren't allowed as custom
/// tags. See [isCustomTagName].
const invalidTagNames = const {
'annotation-xml': '',
'color-profile': '',
'font-face': '',
'font-face-src': '',
'font-face-uri': '',
'font-face-format': '',
'font-face-name': '',
'missing-glyph': '',
/// HTML attributes that expect a URL value.
/// <>
/// 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
const _urlAttributes = const [
'_action', // in form
'_background', // in body
'_cite', // in blockquote, del, ins, q
'_data', // in object
'_formaction', // in button, input
'_href', // in a, area, link, base, command
'_icon', // in command
'_manifest', // in html
'_poster', // in video
'_src', // in audio, embed, iframe, img, input, script, source, track,video