blob: b3786f27b2d2bac9c96ecfde9f77e09c97cbd7d3 [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.
library web_components.build.script_compactor;
import 'dart:async';
import 'package:analyzer/analyzer.dart';
import 'package:barback/barback.dart';
import 'package:code_transformers/assets.dart';
import 'package:code_transformers/messages/build_logger.dart';
import 'package:html5lib/dom.dart' as dom;
import 'package:html5lib/parser.dart' as parser;
import 'package:path/path.dart' as path;
import 'package:source_maps/refactor.dart' show TextEditTransaction;
import 'package:source_span/source_span.dart';
import 'common.dart';
import 'import_crawler.dart';
import 'messages.dart';
/// Transformer which combines all dart scripts found in html imports into one
/// new bootstrap file, and replaces the old entry point script with that file.
///
/// Note: Does not delete the original script files (it can't because the
/// imports may live in other packages). The [ImportInlinerTransformer] will not
/// copy scripts when inlining imports into your entry point to compensate for
/// this.
class ScriptCompactorTransformer extends Transformer {
final List<String> entryPoints;
ScriptCompactorTransformer([this.entryPoints]);
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/')) &&
id.path.endsWith('.html');
}
apply(Transform transform) {
var logger = new BuildLogger(transform);
return new ScriptCompactor(transform, transform.primaryInput.id, logger)
.run()
.then((Asset bootstrap) {
if (bootstrap == null) return null;
return transform.primaryInput.readAsString().then((html) {
var doc = parser.parse(html);
var mainScriptTag = doc.querySelector('script[type="$dartType"]');
mainScriptTag.attributes['src'] =
_importPath(bootstrap.id, transform.primaryInput.id);
mainScriptTag.text = '';
transform.addOutput(
new Asset.fromString(transform.primaryInput.id, doc.outerHtml));
});
});
}
}
/// Helper class which does all the script compacting for a single entry point.
class ScriptCompactor {
/// Can be an AggregateTransform or Transform
final transform;
/// The primary input to start from.
final AssetId primaryInput;
/// The logger to use.
final BuildLogger logger;
/// How many inline scripts were extracted.
int inlineScriptCounter = 0;
/// Id representing the dart script which lives in the primaryInput.
AssetId mainScript;
/// Ids of all the scripts found in html imports.
final Set<AssetId> importScripts = new Set<AssetId>();
ScriptCompactor(this.transform, this.primaryInput, this.logger);
Future<Asset> run() {
var crawler = new ImportCrawler(transform, primaryInput, logger);
return crawler.crawlImports().then((imports) {
Future extractScripts(id) =>
_extractInlineScripts(id, imports[id].document);
return Future.forEach(imports.keys, extractScripts).then((_) {
if (mainScript == null) {
logger.error(
exactlyOneScriptPerEntryPoint.create({'url': primaryInput.path}));
return null;
}
var primaryDocument = imports[primaryInput].document;
assert(primaryDocument != null);
// Create the new bootstrap file and return its AssetId.
return _buildBootstrapFile(mainScript, importScripts);
});
});
}
/// Builds the bootstrap file and returns the path to it relative to
/// [primaryInput].
Asset _buildBootstrapFile(AssetId mainScript, Set<AssetId> importScripts) {
var bootstrapId = new AssetId(mainScript.package,
mainScript.path.replaceFirst('.dart', '.bootstrap.dart'));
var buffer = new StringBuffer();
buffer.writeln('library ${_libraryNameFor(bootstrapId)};');
buffer.writeln();
var i = 0;
for (var script in importScripts) {
var path = _importPath(script, primaryInput);
buffer.writeln("import '$path' as i$i;");
i++;
}
var mainScriptPath = _importPath(mainScript, primaryInput);
buffer.writeln("import '$mainScriptPath' as i$i;");
buffer.writeln();
buffer.writeln('void main() { i$i.main(); }');
var bootstrap = new Asset.fromString(bootstrapId, '$buffer');
transform.addOutput(bootstrap);
return bootstrap;
}
/// Split inline scripts into their own files. We need to do this for dart2js
/// to be able to compile them.
///
/// This also validates that there weren't any duplicate scripts.
Future _extractInlineScripts(AssetId asset, dom.Document doc) {
var scripts = doc.querySelectorAll('script[type="$dartType"]');
return Future.forEach(scripts, (script) {
var type = script.attributes['type'];
var src = script.attributes['src'];
if (src != null) {
return _addScript(
asset, uriToAssetId(asset, src, logger, script.sourceSpan),
span: script.sourceSpan);
}
final count = inlineScriptCounter++;
var code = script.text;
// TODO(sigmund): ensure this path is unique (dartbug.com/12618).
var newId = primaryInput.addExtension('.$count.dart');
if (!_hasLibraryDirective(code)) {
var libName = _libraryNameFor(primaryInput, count);
code = "library $libName;\n$code";
}
// Normalize dart import paths.
code = _normalizeDartImports(code, asset, primaryInput);
// Write out the file and record it.
transform.addOutput(new Asset.fromString(newId, code));
return _addScript(asset, newId, validate: false).then((_) {
// If in the entry point, replace the inline script with one pointing to
// the new source file.
if (primaryInput == asset) {
script.text = '';
script.attributes['src'] = path.url.relative(newId.path,
from: path.url.dirname(primaryInput.path));
}
});
});
}
// Normalize dart import paths when moving code from one asset to another.
String _normalizeDartImports(String code, AssetId from, AssetId to) {
var unit = parseDirectives(code, suppressErrors: true);
var file = new SourceFile(code, url: spanUrlFor(from, to, logger));
var output = new TextEditTransaction(code, file);
var foundLibraryDirective = false;
for (Directive directive in unit.directives) {
if (directive is UriBasedDirective) {
var uri = directive.uri.stringValue;
var span = getSpan(file, directive.uri);
var id = uriToAssetId(from, uri, logger, span, errorOnAbsolute: false);
if (id == null) continue;
var primaryId = primaryInput;
var newUri = assetUrlFor(id, primaryId, logger);
if (newUri != uri) {
output.edit(span.start.offset, span.end.offset, "'$newUri'");
}
} else if (directive is LibraryDirective) {
foundLibraryDirective = true;
}
}
if (!output.hasEdits) return code;
// TODO(sigmund): emit source maps when barback supports it (see
// dartbug.com/12340)
return (output.commit()..build(file.url.toString())).text;
}
Future _addScript(AssetId from, AssetId scriptId,
{bool validate: true, SourceSpan span}) {
var validateFuture;
if (validate && !importScripts.contains(scriptId)) {
validateFuture = transform.hasInput(scriptId);
} else {
validateFuture = new Future.value(true);
}
return validateFuture.then((exists) {
if (!exists) {
logger.warning(scriptFileNotFound.create({'url': scriptId}),
span: span);
}
if (from == primaryInput) {
if (mainScript != null) {
logger
.error(exactlyOneScriptPerEntryPoint.create({'url': from.path}));
}
mainScript = scriptId;
} else {
importScripts.add(scriptId);
}
});
}
}
/// Generate a library name for an asset.
String _libraryNameFor(AssetId id, [int suffix]) {
var name = '${path.withoutExtension(id.path)}_'
'${path.extension(id.path).substring(1)}';
if (name.startsWith('lib/')) name = name.substring(4);
name = name.split('/').map((part) {
part = part.replaceAll(_invalidLibCharsRegex, '_');
if (part.startsWith(_numRegex)) part = '_${part}';
return part;
}).join(".");
var suffixString = suffix != null ? '_$suffix' : '';
return '${id.package}.${name}$suffixString';
}
/// Parse [code] and determine whether it has a library directive.
bool _hasLibraryDirective(String code) =>
parseDirectives(code, suppressErrors: true).directives
.any((d) => d is LibraryDirective);
/// Returns the dart import path to reach [id] relative to [primaryInput].
String _importPath(AssetId id, AssetId primaryInput) {
var parts = path.url.split(id.path);
if (parts[0] == 'lib') {
parts[0] = id.package;
return 'package:${path.url.joinAll(parts)}';
}
return path.url.relative(id.path, from: path.url.dirname(primaryInput.path));
}
// Constant and final variables
final _invalidLibCharsRegex = new RegExp('[^a-z0-9_]');
final _numRegex = new RegExp('[0-9]');