diff --git a/packages/bundler/.clang-format b/packages/bundler/.clang-format new file mode 100644 index 000000000..cfe46005b --- /dev/null +++ b/packages/bundler/.clang-format @@ -0,0 +1,10 @@ +BasedOnStyle: Google +AlignAfterOpenBracket: AlwaysBreak +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +BinPackArguments: false +BinPackParameters: false diff --git a/packages/bundler/.editorconfig b/packages/bundler/.editorconfig new file mode 100644 index 000000000..99b853347 --- /dev/null +++ b/packages/bundler/.editorconfig @@ -0,0 +1,19 @@ +# Copyright (c) 2014 The Polymer Project Authors. All rights reserved. +# This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt +# The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt +# The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt +# Code distributed by Google as part of the polymer project is also +# subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt + +# Polymer EditorConfig + +root = true + +[*] +charset = utf-8 +indent_size = 2 +indent_style = space +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/packages/bundler/.gitattributes b/packages/bundler/.gitattributes new file mode 100644 index 000000000..176a458f9 --- /dev/null +++ b/packages/bundler/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/packages/bundler/.github/PULL_REQUEST_TEMPLATE b/packages/bundler/.github/PULL_REQUEST_TEMPLATE new file mode 100644 index 000000000..dc2b53802 --- /dev/null +++ b/packages/bundler/.github/PULL_REQUEST_TEMPLATE @@ -0,0 +1,16 @@ + + + - [ ] CHANGELOG.md has been updated diff --git a/packages/bundler/.gitignore b/packages/bundler/.gitignore new file mode 100644 index 000000000..0be6d6916 --- /dev/null +++ b/packages/bundler/.gitignore @@ -0,0 +1,5 @@ +node_modules +example/bower_components +**/.DS_Store +typings +lib diff --git a/packages/bundler/.npmignore b/packages/bundler/.npmignore new file mode 100644 index 000000000..999d6ad2a --- /dev/null +++ b/packages/bundler/.npmignore @@ -0,0 +1,7 @@ +node_modules +# npmignore +example +CHANGELOG.md +README.md +util +test diff --git a/packages/bundler/.travis.yml b/packages/bundler/.travis.yml new file mode 100644 index 000000000..10ae1c064 --- /dev/null +++ b/packages/bundler/.travis.yml @@ -0,0 +1,5 @@ +language: node_js +node_js: + - "6" + - "node" +sudo: false diff --git a/packages/bundler/.vscode/settings.json b/packages/bundler/.vscode/settings.json new file mode 100644 index 000000000..317a7d506 --- /dev/null +++ b/packages/bundler/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "clang-format.style": "file", + "editor.detectIndentation": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "editor.tabSize": 2, + "files.insertFinalNewline": true, + "search.exclude": { + "node_modules/": true, + "lib/": true + }, + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/packages/bundler/CHANGELOG.md b/packages/bundler/CHANGELOG.md new file mode 100644 index 000000000..3294f9a50 --- /dev/null +++ b/packages/bundler/CHANGELOG.md @@ -0,0 +1,774 @@ +# Change Log + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + + + + +## 4.0.0-pre.4 - 2018-04-02 +- Fix issue where external script tags referencing bundled ES modules were not updated. + +## 4.0.0-pre.3 - 2018-03-28 +- [BREAKING] The `--in-html` and `--out-html` arguments for `bin/polymer-bundler` are now `--in-file` and `--out-file` because input filetypes are no longer limited to HTML. +- ES6 Module Bundling! Bundler can simultaneously process HTML imports and ES6 module imports, including inline `` + moduleScriptImports: Map, +}; + +/** + * These are the options included in every `Document#getFeatures` call, DRY'd up + * here for brevity and consistency. + */ +const getFeaturesOptions = { + imported: false, + externalPackages: true, + excludeBackreferences: true, +}; + +/** + * For a given document, return a set of transitive dependencies, including + * all eagerly-loaded dependencies and lazy html imports encountered. + */ +function getDependencies( + analyzer: Analyzer, document: Document): DependencyMapEntry { + const deps = new Set(); + const eagerDeps = new Set(); + const lazyImports = new Set(); + const moduleScriptImports = new Map(); + _getDependencies(document, true); + return {deps, eagerDeps, lazyImports, moduleScriptImports}; + + function _getDependencies(document: Document, viaEager: boolean) { + // HTML document dependencies include external modules referenced by script + // src attribute, external modules imported by inline module import + // statements, and HTML imports (recursively). + if (document.kinds.has('html-document')) { + _getHtmlExternalModuleDependencies(document); + _getHtmlInlineModuleDependencies(document); + _getImportDependencies( + document.getFeatures({kind: 'html-import', ...getFeaturesOptions}), + viaEager); + } + + // JavaScript documents, when parsed as modules, have dependencies defined + // by their import statements. + if (document.kinds.has('js-document')) { + _getImportDependencies( + // TODO(usergenic): We should be able to filter here on: + // `.filter((d) => d.parsedAsSourceType === 'module')` + // here, but Analyzer wont report that if there are no + // import/export statements in the imported file. + document.getFeatures({kind: 'js-import', ...getFeaturesOptions}) as + Set, + viaEager); + } + } + + function _getHtmlExternalModuleDependencies(document: Document) { + let externalModuleCount = 0; + const htmlScripts = + [...document.getFeatures({kind: 'html-script', ...getFeaturesOptions})] + .filter( + (i) => i.document !== undefined && + (i.document.parsedDocument as JavaScriptDocument) + .parsedAsSourceType === 'module'); + for (const htmlScript of htmlScripts) { + const relativeUrl = + analyzer.urlResolver.relative(document.url, htmlScript.document!.url); + moduleScriptImports.set( + `external#${++externalModuleCount}>${relativeUrl}>es6-module`, + htmlScript.document!); + } + } + + function _getHtmlInlineModuleDependencies(document: Document) { + let jsDocumentCount = 0; + const jsDocuments = + [...document.getFeatures({kind: 'js-document', ...getFeaturesOptions})] + .filter( + (d) => d.kinds.has('inline-document') && + d.parsedDocument.parsedAsSourceType === 'module'); + for (const jsDocument of jsDocuments) { + moduleScriptImports.set( + `inline#${++jsDocumentCount}>es6-module`, jsDocument); + } + } + + function _getImportDependencies( + imports: Iterable, viaEager: boolean) { + for (const imprt of imports) { + if (imprt.document === undefined) { + continue; + } + const importUrl = imprt.document.url; + if (imprt.lazy) { + lazyImports.add(importUrl); + } + if (eagerDeps.has(importUrl)) { + continue; + } + const isEager = viaEager && !lazyImports.has(importUrl); + if (isEager) { + // In this case we've visited a node eagerly for the first time, + // so recurse. + eagerDeps.add(importUrl); + } else if (deps.has(importUrl)) { + // In this case we're seeing a node lazily again, so don't recurse + continue; + } + deps.add(importUrl); + _getDependencies(imprt.document, isEager); + } + } +} diff --git a/packages/bundler/src/document-collection.ts b/packages/bundler/src/document-collection.ts new file mode 100644 index 000000000..964e24c9b --- /dev/null +++ b/packages/bundler/src/document-collection.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright (c) 2016 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +import {ASTNode} from 'parse5'; +import {ResolvedUrl} from 'polymer-analyzer'; + +export interface BundledDocument { + ast: ASTNode; + content: string; + files: ResolvedUrl[]; +} + +/* A collection of documents, keyed by path */ +export type DocumentCollection = Map; diff --git a/packages/bundler/src/es6-module-bundler.ts b/packages/bundler/src/es6-module-bundler.ts new file mode 100644 index 000000000..3d6d52215 --- /dev/null +++ b/packages/bundler/src/es6-module-bundler.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright (c) 2018 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +import generate from 'babel-generator'; +import * as babel from 'babel-types'; +import {ResolvedUrl} from 'polymer-analyzer'; + +import {getAnalysisDocument} from './analyzer-utils'; +import {AssignedBundle, BundleManifest} from './bundle-manifest'; +import {Bundler} from './bundler'; +import {BundledDocument} from './document-collection'; +import {getModuleExportNames, getOrSetBundleModuleExportName} from './es6-module-utils'; +import {Es6Rewriter} from './es6-rewriter'; +import {ensureLeadingDot, stripUrlFileSearchAndHash} from './url-utils'; + +/** + * Produces an ES6 Module BundledDocument. + */ +export async function bundle( + bundler: Bundler, manifest: BundleManifest, url: ResolvedUrl): + Promise { + const bundle = manifest.bundles.get(url); + if (!bundle) { + throw new Error(`No bundle found in manifest for url ${url}.`); + } + const assignedBundle = {url, bundle}; + const generatedCode = + await prepareBundleModule(bundler, manifest, assignedBundle); + const es6Rewriter = new Es6Rewriter(bundler, manifest, assignedBundle); + const {code: rolledUpCode} = await es6Rewriter.rollup(url, generatedCode); + const document = + await bundler.analyzeContents(assignedBundle.url, rolledUpCode); + return { + ast: document.parsedDocument.ast, + content: document.parsedDocument.contents, + files: [...assignedBundle.bundle.files] + }; +} + +/** + * Generate code containing import statements to all bundled modules and + * export statements to re-export their namespaces and exports. + * + * Example: a bundle containing files `module-a.js` and `module-b.js` would + * result in a prepareBundleModule result like: + * + * import * as $moduleA from './module-a.js'; + * import * as $moduleB from './module-b.js'; + * import $moduleBDefault from './module-b.js'; + * export {thing1, thing2} from './module-a.js'; + * export {thing3} from './module-b.js'; + * export {$moduleA, $moduleB, $moduleBDefault}; + */ +async function prepareBundleModule( + bundler: Bundler, manifest: BundleManifest, assignedBundle: AssignedBundle): + Promise { + let bundleSource = babel.program([]); + const sourceAnalysis = + await bundler.analyzer.analyze([...assignedBundle.bundle.files]); + for (const sourceUrl of [...assignedBundle.bundle.files].sort()) { + const rebasedSourceUrl = + ensureLeadingDot(bundler.analyzer.urlResolver.relative( + stripUrlFileSearchAndHash(assignedBundle.url), sourceUrl)); + const moduleDocument = getAnalysisDocument(sourceAnalysis, sourceUrl); + const moduleExports = getModuleExportNames(moduleDocument); + const starExportName = + getOrSetBundleModuleExportName(assignedBundle, sourceUrl, '*'); + bundleSource.body.push(babel.importDeclaration( + [babel.importNamespaceSpecifier(babel.identifier(starExportName))], + babel.stringLiteral(rebasedSourceUrl))); + if (moduleExports.size > 0) { + bundleSource.body.push(babel.exportNamedDeclaration( + undefined, [babel.exportSpecifier( + babel.identifier(starExportName), + babel.identifier(starExportName))])); + bundleSource.body.push(babel.exportNamedDeclaration( + undefined, + [...moduleExports].map( + (e) => babel.exportSpecifier( + babel.identifier(e), + babel.identifier(getOrSetBundleModuleExportName( + assignedBundle, sourceUrl, e)))), + babel.stringLiteral(rebasedSourceUrl))); + } + } + const {code} = generate(bundleSource); + return code; + } diff --git a/packages/bundler/src/es6-module-utils.ts b/packages/bundler/src/es6-module-utils.ts new file mode 100644 index 000000000..c72e0196d --- /dev/null +++ b/packages/bundler/src/es6-module-utils.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright (c) 2018 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +import {Analyzer, Document, ResolvedUrl} from 'polymer-analyzer'; + +import {getAnalysisDocument} from './analyzer-utils'; +import {AssignedBundle, BundleManifest} from './bundle-manifest'; +import {getFileName} from './url-utils'; +import {camelCase} from './utils'; + +/** + * Looks up and/or defines the unique name for an item exported with the given + * name in a module within a bundle. + */ +export function getOrSetBundleModuleExportName( + bundle: AssignedBundle, moduleUrl: ResolvedUrl, name: string): string { + let moduleExports = bundle.bundle.bundledExports.get(moduleUrl); + const bundledExports = bundle.bundle.bundledExports; + if (!moduleExports) { + moduleExports = new Map(); + bundledExports.set(moduleUrl, moduleExports); + } + let exportName = moduleExports.get(name); + if (!exportName) { + let trialName = name; + let moduleFileNameIdentifier = + '$' + camelCase(getFileName(moduleUrl).replace(/\.[a-z0-9_]+$/, '')); + trialName = + trialName.replace(/^default$/, `${moduleFileNameIdentifier}Default`) + .replace(/^\*$/, moduleFileNameIdentifier) + .replace(/[^a-z0-9_]/gi, '$'); + while (!exportName) { + if ([...bundledExports.values()].every( + (map) => [...map.values()].indexOf(trialName) === -1)) { + exportName = trialName; + } else { + if (trialName.match(/\$[0-9]+$/)) { + trialName = trialName.replace(/[0-9]+$/, (v) => `${parseInt(v) + 1}`); + } else { + trialName = `${trialName}$1`; + } + } + } + moduleExports.set(name, exportName); + } + return exportName; +} + +/** + * Returns a set of every name exported by a module. + * + * TODO(usergenic): This does not include names brought in by the statement + * `export * from './module-a.js';`. + * https://github.com/Polymer/polymer-bundler/issues/641 + */ +export function getModuleExportNames(document: Document): Set { + const exports_ = document.getFeatures({kind: 'export'}); + const identifiers = new Set(); + for (const export_ of exports_) { + for (const identifier of export_.identifiers) { + identifiers.add(identifier); + } + } + return identifiers; +} + +/** + * Ensures that exported names from modules which have the same URL as their + * bundle will have precedence over other module exports, which will be + * counter-suffixed in the event of name collisions. This has no technical + * benefit, but it results in module export naming choices that are easier + * to reason about for developers and may aid in debugging. + */ +export async function reserveBundleModuleExportNames( + analyzer: Analyzer, manifest: BundleManifest) { + const es6ModuleBundles = + [...manifest.bundles] + .map(([url, bundle]) => ({url, bundle})) + .filter(({bundle}) => bundle.type === 'es6-module'); + const analysis = await analyzer.analyze(es6ModuleBundles.map(({url}) => url)); + for (const {url, bundle} of es6ModuleBundles) { + if (bundle.files.has(url)) { + const document = getAnalysisDocument(analysis, url); + for (const exportName of getModuleExportNames(document)) { + getOrSetBundleModuleExportName({url, bundle}, url, exportName); + } + } + } +} diff --git a/packages/bundler/src/es6-rewriter.ts b/packages/bundler/src/es6-rewriter.ts new file mode 100644 index 000000000..c7d487a05 --- /dev/null +++ b/packages/bundler/src/es6-rewriter.ts @@ -0,0 +1,393 @@ +/** + * @license + * Copyright (c) 2018 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +import traverse, {NodePath} from 'babel-traverse'; +import * as babel from 'babel-types'; +import * as clone from 'clone'; +import {FileRelativeUrl, Import, PackageRelativeUrl, ResolvedUrl} from 'polymer-analyzer'; +import {rollup} from 'rollup'; + +import {getAnalysisDocument} from './analyzer-utils'; +import {serialize} from './babel-utils'; +import {AssignedBundle, BundleManifest} from './bundle-manifest'; +import {Bundler} from './bundler'; +import {getOrSetBundleModuleExportName} from './es6-module-utils'; +import {appendUrlPath, ensureLeadingDot, getFileExtension} from './url-utils'; +import {rewriteObject} from './utils'; + +/** + * Utility class to rollup/merge ES6 modules code using rollup and rewrite + * import statements to point to appropriate bundles. + */ +export class Es6Rewriter { + constructor( + public bundler: Bundler, + public manifest: BundleManifest, + public bundle: AssignedBundle) { + } + + async rollup(url: ResolvedUrl, code: string) { + // This is a synthetic module specifier used to identify the code to rollup + // and differentiate it from the a request to contents of the document at + // the actual given url which should load from the analyzer. + const input = '*bundle*'; + const analysis = + await this.bundler.analyzer.analyze([...this.bundle.bundle.files]); + const external: string[] = []; + for (const [url, bundle] of this.manifest.bundles) { + if (url !== this.bundle.url) { + external.push(...[...bundle.files, url]); + } + } + // For each document loaded from the analyzer, we build a map of the + // original specifiers to the resolved URLs since we want to use analyzer + // resolutions for such things as bare module specifiers. + const jsImportResolvedUrls = + new Map>(); + const rollupBundle = await rollup({ + input, + external, + onwarn: (warning: string) => {}, + treeshake: false, + plugins: [ + { + name: 'analyzerPlugin', + resolveId: (importee: string, importer?: string) => { + if (importee === input) { + return input; + } + if (importer) { + if (jsImportResolvedUrls.has(importer as ResolvedUrl)) { + const resolutions = + jsImportResolvedUrls.get(importer as ResolvedUrl)!; + if (resolutions.has(importee)) { + return resolutions.get(importee); + } + } + return this.bundler.analyzer.urlResolver.resolve( + importer === input ? url : importer as ResolvedUrl, + importee as FileRelativeUrl)! as string; + } + return this.bundler.analyzer.urlResolver.resolve( + importee as PackageRelativeUrl)! as string; + }, + load: (id: ResolvedUrl) => { + if (id === input) { + return code; + } + if (this.bundle.bundle.files.has(id)) { + const document = getAnalysisDocument(analysis, id); + if (!jsImportResolvedUrls.has(id)) { + const jsImports = document.getFeatures({ + kind: 'js-import', + imported: false, + externalPackages: true, + excludeBackreferences: true, + }) as Set; + const resolutions = new Map(); + jsImportResolvedUrls.set(id, resolutions); + for (const jsImport of jsImports) { + const source = jsImport.astNode && jsImport.astNode.source && + jsImport.astNode.source.value; + if (source && jsImport.document !== undefined) { + resolutions.set(source, jsImport.document.url); + } + } + } + return document.parsedDocument.contents; + } + }, + }, + ], + experimentalDynamicImport: true, + }); + const {code: rolledUpCode} = await rollupBundle.generate({ + format: 'es', + freeze: false, + }); + // We have to force the extension of the URL to analyze here because inline + // es6 module document url is going to end in `.html` and the file would be + // incorrectly analyzed as an HTML document. + const rolledUpUrl = getFileExtension(url) === '.js' ? + url : + appendUrlPath(url, '_inline_es6_module.js'); + const rolledUpDocument = await this.bundler.analyzeContents( + rolledUpUrl as ResolvedUrl, rolledUpCode); + const babelFile = rolledUpDocument.parsedDocument.ast; + this._rewriteImportStatements(url, babelFile); + this._deduplicateImportStatements(babelFile); + const {code: rewrittenCode} = serialize(babelFile); + return {code: rewrittenCode, map: undefined}; + } + + /** + * Attempts to reduce the number of distinct import declarations by combining + * those referencing the same source into the same declaration. Results in + * deduplication of imports of the same item as well. + * + * Before: + * import {a} from './module-1.js'; + * import {b} from './module-1.js'; + * import {c} from './module-2.js'; + * After: + * import {a,b} from './module-1.js'; + * import {c} from './module-2.js'; + */ + private _deduplicateImportStatements(node: babel.Node) { + const importDeclarations = new Map(); + traverse(node, { + noScope: true, + ImportDeclaration: { + enter(path: NodePath) { + const importDeclaration = path.node; + if (!babel.isImportDeclaration(importDeclaration)) { + return; + } + const source = babel.isStringLiteral(importDeclaration.source) && + importDeclaration.source.value; + if (!source) { + return; + } + const hasNamespaceSpecifier = importDeclaration.specifiers.some( + (s) => babel.isImportNamespaceSpecifier(s)); + const hasDefaultSpecifier = importDeclaration.specifiers.some( + (s) => babel.isImportDefaultSpecifier(s)); + if (!importDeclarations.has(source) && !hasNamespaceSpecifier && + !hasDefaultSpecifier) { + importDeclarations.set(source, importDeclaration); + } else if (importDeclarations.has(source)) { + const existingDeclaration = importDeclarations.get(source)!; + for (const specifier of importDeclaration.specifiers) { + existingDeclaration.specifiers.push(specifier); + } + path.remove(); + } + } + } + }); + } + + /** + * Rewrite import declarations source URLs reference the bundle URL for + * bundled files and import names to correspond to names as exported by + * bundles. + */ + private _rewriteImportStatements(baseUrl: ResolvedUrl, node: babel.Node) { + const this_ = this; + traverse(node, { + noScope: true, + // Dynamic import() syntax doesn't have full type support yet, so we + // have to use generic `enter` and walk all nodes until that's fixed. + // TODO(usergenic): Switch this to the `Import: { enter }` style + // after dynamic imports fully supported. + enter(path: NodePath) { + if (path.node.type === 'Import') { + this_._rewriteDynamicImport(baseUrl, node, path); + } + }, + }); + + traverse(node, { + noScope: true, + ImportDeclaration: { + enter(path: NodePath) { + const importDeclaration = path.node as babel.ImportDeclaration; + if (!babel.isStringLiteral(importDeclaration.source)) { + // We can't actually handle values which are not string literals, so + // we'll skip them. + return; + } + const source = importDeclaration.source.value as ResolvedUrl; + const sourceBundle = this_.manifest.getBundleForFile(source); + // If there is no import bundle, then this URL is not bundled (maybe + // excluded or something) so we should just ensure the URL is + // converted back to a relative URL. + if (!sourceBundle) { + importDeclaration.source.value = + this_.bundler.analyzer.urlResolver.relative(baseUrl, source); + return; + } + for (const specifier of importDeclaration.specifiers) { + if (babel.isImportSpecifier(specifier)) { + this_._rewriteImportSpecifierName( + specifier, source, sourceBundle); + } + if (babel.isImportDefaultSpecifier(specifier)) { + this_._rewriteImportDefaultSpecifier( + specifier, source, sourceBundle); + } + if (babel.isImportNamespaceSpecifier(specifier)) { + this_._rewriteImportNamespaceSpecifier( + specifier, source, sourceBundle); + } + } + importDeclaration.source.value = + ensureLeadingDot(this_.bundler.analyzer.urlResolver.relative( + baseUrl, sourceBundle.url)); + } + } + }); + } + + /** + * Extends dynamic import statements to extract the explicitly namespace + * export for the imported module. + * + * Before: + * import('./module-a.js') + * .then((moduleA) => moduleA.doSomething()); + * + * After: + * import('./bundle_1.js') + * .then(({$moduleA}) => $moduleA) + * .then((moduleA) => moduleA.doSomething()); + */ + private _rewriteDynamicImport( + baseUrl: ResolvedUrl, + root: babel.Node, + importNodePath: NodePath) { + if (!importNodePath) { + return; + } + const importCallExpression = importNodePath.parent; + if (!importCallExpression || + !babel.isCallExpression(importCallExpression)) { + return; + } + const importCallArgument = importCallExpression.arguments[0]; + if (!babel.isStringLiteral(importCallArgument)) { + return; + } + const sourceUrl = importCallArgument.value; + const resolvedSourceUrl = this.bundler.analyzer.urlResolver.resolve( + baseUrl, sourceUrl as FileRelativeUrl); + if (!resolvedSourceUrl) { + return; + } + const sourceBundle = this.manifest.getBundleForFile(resolvedSourceUrl); + // TODO(usergenic): To support *skipping* the rewrite, we need a way to + // identify whether a bundle contains a single top-level module or is a + // merged bundle with multiple top-level modules. + let exportName; + if (sourceBundle) { + exportName = + getOrSetBundleModuleExportName(sourceBundle, resolvedSourceUrl, '*'); + } + // If there's no source bundle or the namespace export name of the bundle + // is just '*', then we don't need to append a .then() to transform the + // return value of the import(). Lets just rewrite the URL to be a relative + // path and exit. + if (!sourceBundle || exportName === '*') { + const relativeSourceUrl = + ensureLeadingDot(this.bundler.analyzer.urlResolver.relative( + baseUrl, resolvedSourceUrl)); + importCallArgument.value = relativeSourceUrl; + return; + } + // Rewrite the URL to be a relative path to the bundle. + const relativeSourceUrl = ensureLeadingDot( + this.bundler.analyzer.urlResolver.relative(baseUrl, sourceBundle.url)); + importCallArgument.value = relativeSourceUrl; + const importCallExpressionParent = importNodePath.parentPath.parent!; + if (!importCallExpressionParent) { + return; + } + const thenifiedCallExpression = babel.callExpression( + babel.memberExpression( + clone(importCallExpression), babel.identifier('then')), + [babel.arrowFunctionExpression( + [ + babel.objectPattern( + [babel.objectProperty( + babel.identifier(exportName), + babel.identifier(exportName), + undefined, + true) as any]), + ], + babel.identifier(exportName))]); + rewriteObject(importCallExpression, thenifiedCallExpression); + } + + /** + * Changes an import specifier to use the exported name defined in the bundle. + * + * Before: + * import {something} from './module-a.js'; + * + * After: + * import {something_1} from './bundle_1.js'; + */ + private _rewriteImportSpecifierName( + specifier: babel.ImportSpecifier, + source: ResolvedUrl, + sourceBundle: AssignedBundle) { + const originalExportName = specifier.imported.name; + const exportName = getOrSetBundleModuleExportName( + sourceBundle, source, originalExportName); + specifier.imported.name = exportName; + } + + /** + * Changes an import specifier to use the exported name for original module's + * default as defined in the bundle. + * + * Before: + * import moduleA from './module-a.js'; + * + * After: + * import {$moduleADefault} from './bundle_1.js'; + */ + private _rewriteImportDefaultSpecifier( + specifier: babel.ImportDefaultSpecifier, + source: ResolvedUrl, + sourceBundle: AssignedBundle) { + const exportName = + getOrSetBundleModuleExportName(sourceBundle, source, 'default'); + // No rewrite necessary if default is the name, since this indicates there + // was no rewriting or bundling of the default export. + if (exportName === 'default') { + return; + } + const importSpecifier = specifier as any as babel.ImportSpecifier; + Object.assign( + importSpecifier, + {type: 'ImportSpecifier', imported: babel.identifier(exportName)}); + } + + /** + * Changes an import specifier to use the exported name for original module's + * namespace as defined in the bundle. + * + * Before: + * import * as moduleA from './module-a.js'; + * + * After: + * import {$moduleA} from './bundle_1.js'; + */ + private _rewriteImportNamespaceSpecifier( + specifier: babel.ImportNamespaceSpecifier, + source: ResolvedUrl, + sourceBundle: AssignedBundle) { + const exportName = + getOrSetBundleModuleExportName(sourceBundle, source, '*'); + // No rewrite necessary if * is the name, since this indicates there was no + // bundling of the namespace. + if (exportName === '*') { + return; + } + const importSpecifier = specifier as any as babel.ImportSpecifier; + Object.assign( + importSpecifier, + {type: 'ImportSpecifier', imported: babel.identifier(exportName)}); + } +} diff --git a/packages/bundler/src/html-bundler.ts b/packages/bundler/src/html-bundler.ts new file mode 100644 index 000000000..860d964bf --- /dev/null +++ b/packages/bundler/src/html-bundler.ts @@ -0,0 +1,919 @@ +/** + * @license + * Copyright (c) 2018 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ + +import * as clone from 'clone'; +import * as dom5 from 'dom5'; +import {ASTNode, parseFragment, serialize, treeAdapters} from 'parse5'; +import {Document, FileRelativeUrl, ParsedHtmlDocument, ResolvedUrl} from 'polymer-analyzer'; + +import {getAnalysisDocument} from './analyzer-utils'; +import {AssignedBundle, BundleManifest} from './bundle-manifest'; +import {Bundler} from './bundler'; +import constants from './constants'; +import {BundledDocument} from './document-collection'; +import {Es6Rewriter} from './es6-rewriter'; +import * as matchers from './matchers'; +import {findAncestor, insertAfter, insertAllBefore, inSourceOrder, isSameNode, prepend, removeElementAndNewline, siblingsAfter, stripComments} from './parse5-utils'; +import {addOrUpdateSourcemapComment} from './source-map'; +import {updateSourcemapLocations} from './source-map'; +import encodeString from './third_party/UglifyJS2/encode-string'; +import {ensureTrailingSlash, getFileExtension, isTemplatedUrl, rewriteHrefBaseUrl, stripUrlFileSearchAndHash} from './url-utils'; +import {find, rewriteObject} from './utils'; + +/** + * Produces an HTML BundledDocument. + */ +export async function bundle( + bundler: Bundler, manifest: BundleManifest, url: ResolvedUrl): + Promise { + const bundle = manifest.bundles.get(url); + if (!bundle) { + throw new Error(`No bundle found in manifest for url ${url}.`); + } + const assignedBundle = {url, bundle}; + const htmlBundler = new HtmlBundler(bundler, assignedBundle, manifest); + return htmlBundler.bundle(); +} + +/** + * A single-use instance of this class produces a single HTML BundledDocument. + * Use the bundle directly is deprecated; it is exported only to support unit + * tests of its methods in html-bundler_test.ts for now. Please use the + * exported bundle function above. + */ +export class HtmlBundler { + protected document: Document; + + constructor( + public bundler: Bundler, + public assignedBundle: AssignedBundle, + public manifest: BundleManifest) { + } + + async bundle(): Promise { + this.document = await this._prepareBundleDocument(); + let ast = clone(this.document.parsedDocument.ast); + dom5.removeFakeRootElements(ast); + this._injectHtmlImportsForBundle(ast); + this._rewriteAstToEmulateBaseTag(ast, this.assignedBundle.url); + + // Re-analyzing the document using the updated ast to refresh the scanned + // imports, since we may now have appended some that were not initially + // present. + this.document = await this._reanalyze(serialize(ast)); + + await this._inlineHtmlImports(ast); + + await this._updateExternalModuleScripts(ast); + if (this.bundler.enableScriptInlining) { + await this._inlineNonModuleScripts(ast); + await this._inlineModuleScripts(ast); + } + if (this.bundler.enableCssInlining) { + await this._inlineStylesheetLinks(ast); + await this._inlineStylesheetImports(ast); + } + if (this.bundler.stripComments) { + stripComments(ast); + } + this._removeEmptyHiddenDivs(ast); + if (this.bundler.sourcemaps) { + ast = updateSourcemapLocations(this.document, ast); + } + const content = serialize(ast); + const files = [...this.assignedBundle.bundle.files]; + return {ast, content, files}; + } + + /** + * Walk through inline scripts of an import document. + * For each script create identity source maps unless one already exists. + * + * The generated script mapping detail is the relative location within + * the script tag. Later this will be updated to account for the + * line offset within the final bundle. + */ + private async _addOrUpdateSourcemapsForInlineScripts( + originalDoc: Document, + reparsedDoc: ParsedHtmlDocument, + oldBaseUrl: ResolvedUrl) { + const inlineScripts = + dom5.queryAll(reparsedDoc.ast, matchers.inlineNonModuleScript); + const promises = inlineScripts.map(scriptAst => { + let content = dom5.getTextContent(scriptAst); + const sourceRange = reparsedDoc.sourceRangeForStartTag(scriptAst)!; + return addOrUpdateSourcemapComment( + this.bundler.analyzer, + oldBaseUrl, + content, + sourceRange.end.line, + sourceRange.end.column, + -sourceRange.end.line + 1, + -sourceRange.end.column) + .then(updatedContent => { + dom5.setTextContent(scriptAst, encodeString(updatedContent)); + }); + }); + + return Promise.all(promises); + } + + /** + * Set the hidden div at the appropriate location within the document. The + * goal is to place the hidden div at the same place as the first html + * import. However, the div can't be placed in the `` of the document + * so if first import is found in the head, we prepend the div to the body. + * If there is no body, we'll just attach the hidden div to the document at + * the end. + */ + private _attachHiddenDiv(ast: ASTNode, hiddenDiv: ASTNode) { + const firstHtmlImport = dom5.query(ast, matchers.eagerHtmlImport); + const body = dom5.query(ast, matchers.body); + if (body) { + if (firstHtmlImport && + dom5.predicates.parentMatches(matchers.body)(firstHtmlImport)) { + insertAfter(firstHtmlImport, hiddenDiv); + } else { + prepend(body, hiddenDiv); + } + } else { + dom5.append(ast, hiddenDiv); + } + } + + /** + * Creates a hidden container
to which inlined content will be + * appended. + */ + private _createHiddenDiv(): ASTNode { + const hidden = dom5.constructors.element('div'); + dom5.setAttribute(hidden, 'hidden', ''); + dom5.setAttribute(hidden, 'by-polymer-bundler', ''); + return hidden; + } + + /** + * Append a `` node to `node` with a value of `url` + * for the "href" attribute. + */ + private _createHtmlImport(url: FileRelativeUrl|ResolvedUrl): ASTNode { + const link = dom5.constructors.element('link'); + dom5.setAttribute(link, 'rel', 'import'); + dom5.setAttribute(link, 'href', url); + return link; + } + + /** + * Given a document, search for the hidden div, if it isn't found, then + * create it. After creating it, attach it to the desired location. Then + * return it. + */ + private _findOrCreateHiddenDiv(ast: ASTNode): ASTNode { + const hiddenDiv = + dom5.query(ast, matchers.hiddenDiv) || this._createHiddenDiv(); + if (!hiddenDiv.parentNode) { + this._attachHiddenDiv(ast, hiddenDiv); + } + return hiddenDiv; + } + + /** + * Add HTML Import elements for each file in the bundle. Efforts are made + * to ensure that imports are injected prior to any eager imports of other + * bundles which are known to depend on them, to preserve expectations of + * evaluation order. + */ + private _injectHtmlImportsForBundle(ast: ASTNode) { + // Gather all the document's direct html imports. We want the direct (not + // transitive) imports only here, because we'll be using their AST nodes + // as targets to prepended injected imports to. + const existingImports = [ + ...this.document.getFeatures( + {kind: 'html-import', noLazyImports: true, imported: false}) + ].filter((i) => !i.lazy && i.document !== undefined); + const existingImportDependencies = new Map(); + for (const {url, document} of existingImports) { + existingImportDependencies.set( + url, + [...document!.getFeatures({ + kind: 'html-import', + imported: true, + noLazyImports: true, + })].filter((i) => i.lazy === false && i.document !== undefined) + .map((i) => i.document!.url)); + } + // Every HTML file in the bundle is a candidate for injection into the + // document. + for (const importUrl of this.assignedBundle.bundle.files) { + // We only want to inject an HTML import to an HTML file. + if (getFileExtension(importUrl) !== '.html') { + continue; + } + + // We don't want to inject the bundle into itself. + if (this.assignedBundle.url === importUrl) { + continue; + } + + // If there is an existing import in the document that matches the + // import URL already, we don't need to inject one. + if (existingImports.find( + (e) => + e.document !== undefined && e.document.url === importUrl)) { + continue; + } + + // We are looking for the earliest eager import of an html document + // which has a dependency on the html import we want to inject. + let prependTarget = undefined; + + // We are only concerned with imports that are not of files in this + // bundle. + for (const existingImport of existingImports.filter( + (e) => e.document !== undefined && + !this.assignedBundle.bundle.files.has(e.document.url))) { + // If the existing import has a dependency on the import we are + // about to inject, it may be our new target. + if (existingImportDependencies.get(existingImport.document!.url)! + .indexOf(importUrl) !== -1) { + const newPrependTarget = dom5.query( + ast, (node) => isSameNode(node, existingImport.astNode)); + + // IF we don't have a target already or if the old target comes + // after the new one in the source code, the new one will replace + // the old one. + if (newPrependTarget && + (!prependTarget || + inSourceOrder(newPrependTarget, prependTarget))) { + prependTarget = newPrependTarget; + } + } + } + + // Inject the new html import into the document. + const relativeImportUrl = this.bundler.analyzer.urlResolver.relative( + this.assignedBundle.url, importUrl); + const newHtmlImport = this._createHtmlImport(relativeImportUrl); + if (prependTarget) { + dom5.insertBefore( + prependTarget.parentNode!, prependTarget, newHtmlImport); + } else { + const hiddenDiv = this._findOrCreateHiddenDiv(ast); + dom5.append(hiddenDiv.parentNode!, newHtmlImport); + } + } + } + + /** + * Inline the contents of the html document returned by the link tag's href + * at the location of the link tag and then remove the link tag. If the + * link is a `lazy-import` link, content will not be inlined. + */ + private async _inlineHtmlImport(linkTag: ASTNode) { + const isLazy = dom5.getAttribute(linkTag, 'rel')!.match(/lazy-import/i); + const importHref = dom5.getAttribute(linkTag, 'href')! as FileRelativeUrl; + const resolvedImportUrl = this.bundler.analyzer.urlResolver.resolve( + this.assignedBundle.url, importHref); + if (resolvedImportUrl === undefined) { + return; + } + const importBundle = this.manifest.getBundleForFile(resolvedImportUrl); + + // We don't want to process the same eager import again, but we want to + // process every lazy import we see. + if (!isLazy) { + // Just remove the import from the DOM if it is in the stripImports Set. + if (this.assignedBundle.bundle.stripImports.has(resolvedImportUrl)) { + removeElementAndNewline(linkTag); + return; + } + + // We've never seen this import before, so we'll add it to the + // stripImports Set to guard against inlining it again in the future. + this.assignedBundle.bundle.stripImports.add(resolvedImportUrl); + } + + // If we can't find a bundle for the referenced import, we will just leave + // the import link alone. Unless the file was specifically excluded, we + // need to record it as a "missing import". + if (!importBundle) { + if (!this.bundler.excludes.some( + (u) => u === resolvedImportUrl || + resolvedImportUrl.startsWith(ensureTrailingSlash(u)))) { + this.assignedBundle.bundle.missingImports.add(resolvedImportUrl); + } + return; + } + + // Don't inline an import into itself. + if (this.assignedBundle.url === resolvedImportUrl) { + removeElementAndNewline(linkTag); + return; + } + + const importIsInAnotherBundle = + importBundle.url !== this.assignedBundle.url; + + // If the import is in another bundle and that bundle is in the + // stripImports Set, we should not link to that bundle. + const stripLinkToImportBundle = importIsInAnotherBundle && + this.assignedBundle.bundle.stripImports.has(importBundle.url) && + // We just added resolvedImportUrl to stripImports, so we'll exclude + // the case where resolved import URL is not the import bundle. This + // scenario happens when importing a file from a bundle with the same + // name as the original import, like an entrypoint or lazy edge. + resolvedImportUrl !== importBundle.url; + + // If the html import refers to a file which is bundled and has a + // different URL, then lets just rewrite the href to point to the bundle + // URL. + if (importIsInAnotherBundle) { + // We guard against inlining any other file from a bundle that has + // already been imported. A special exclusion is for lazy imports, + // which are not deduplicated here, since we can not infer developer's + // intent from here. + if (stripLinkToImportBundle && !isLazy) { + removeElementAndNewline(linkTag); + return; + } + + const relative = this.bundler.analyzer.urlResolver.relative( + this.assignedBundle.url, importBundle.url) || + importBundle.url; + dom5.setAttribute(linkTag, 'href', relative); + this.assignedBundle.bundle.stripImports.add(importBundle.url); + return; + } + + // We don't actually inline a `lazy-import` because its loading is + // intended to be deferred until the client requests it. + if (isLazy) { + return; + } + + // If the analyzer could not load the import document, we can't inline it, + // so lets skip it. + const htmlImport = find( + this.document.getFeatures( + {kind: 'html-import', imported: true, externalPackages: true}), + (i) => + i.document !== undefined && i.document.url === resolvedImportUrl); + if (htmlImport === undefined || htmlImport.document === undefined) { + return; + } + + // When inlining html documents, we'll parse it as a fragment so that we + // do not get html, head or body wrappers. + const importAst = parseFragment( + htmlImport.document.parsedDocument.contents, {locationInfo: true}); + this._rewriteAstToEmulateBaseTag(importAst, resolvedImportUrl); + this._rewriteAstBaseUrl(importAst, resolvedImportUrl, this.document.url); + + if (this.bundler.sourcemaps) { + const reparsedDoc = new ParsedHtmlDocument({ + url: this.assignedBundle.url, + baseUrl: this.document.parsedDocument.baseUrl, + contents: htmlImport.document.parsedDocument.contents, + ast: importAst, + isInline: false, + locationOffset: undefined, + astNode: undefined, + }); + await this._addOrUpdateSourcemapsForInlineScripts( + this.document, reparsedDoc, resolvedImportUrl); + } + const nestedImports = dom5.queryAll(importAst, matchers.htmlImport); + + // Move all of the import doc content after the html import. + insertAllBefore(linkTag.parentNode!, linkTag, importAst.childNodes!); + removeElementAndNewline(linkTag); + + // Record that the inlining took place. + this.assignedBundle.bundle.inlinedHtmlImports.add(resolvedImportUrl); + + // Recursively process the nested imports. + for (const nestedImport of nestedImports) { + await this._inlineHtmlImport(nestedImport); + } + } + + /** + * Replace html import links in the document with the contents of the + * imported file, but only once per URL. + */ + private async _inlineHtmlImports(ast: ASTNode) { + const htmlImports = dom5.queryAll(ast, matchers.htmlImport); + for (const htmlImport of htmlImports) { + await this._inlineHtmlImport(htmlImport); + } + } + + /** + * Update the `src` attribute of external `type=module` script tags to point + * at new bundle locations. + */ + public async _updateExternalModuleScripts(ast: ASTNode) { + const scripts = dom5.queryAll(ast, matchers.externalModuleScript); + for (const script of scripts) { + const oldSrc = dom5.getAttribute(script, 'src'); + const oldFileUrl = this.bundler.analyzer.urlResolver.resolve( + this.assignedBundle.url, oldSrc as FileRelativeUrl); + if (oldFileUrl === undefined) { + continue; + } + const bundle = this.manifest.getBundleForFile(oldFileUrl); + if (bundle === undefined) { + continue; + } + const newFileUrl = bundle.url; + const newSrc = this.bundler.analyzer.urlResolver.relative( + this.assignedBundle.url, newFileUrl); + dom5.setAttribute(script, 'src', newSrc); + } + } + + /** + * Inlines the contents of external module scripts and rolls-up imported + * modules into inline scripts. + */ + private async _inlineModuleScripts(ast: ASTNode) { + this.document = await this._reanalyze(serialize(ast)); + rewriteObject(ast, this.document.parsedDocument.ast); + dom5.removeFakeRootElements(ast); + const es6Rewriter = + new Es6Rewriter(this.bundler, this.manifest, this.assignedBundle); + const inlineModuleScripts = + [...this.document.getFeatures({ + kind: 'js-document', + imported: false, + externalPackages: true, + excludeBackreferences: true, + })].filter(({ + isInline, + parsedDocument: {parsedAsSourceType} + }) => isInline && parsedAsSourceType === 'module'); + for (const inlineModuleScript of inlineModuleScripts) { + const {code} = await es6Rewriter.rollup( + this.document.parsedDocument.baseUrl, + inlineModuleScript.parsedDocument.contents); + // Second argument 'true' tells encodeString to escape the '); + assert(idx === -1, '/script found, should be escaped'); + }); + }); + + suite('Inline CSS', () => { + const options = {inlineCss: true}; + + test('URLs for inlined styles are recorded in Bundle', async () => { + await bundle(inputPath); + assert.deepEqual([...documentBundle.inlinedStyles].sort(), [ + 'imports/import-linked-style.css', + 'imports/regular-style.css', + 'imports/simple-style.css', + ].map(resolve)); + }); + + test('External links are replaced with inlined styles', async () => { + const {ast: doc} = await bundle(inputPath, options); + const links = dom5.queryAll(doc, matchers.stylesheetImport); + const styles = dom5.queryAll( + doc, matchers.styleMatcher, [], dom5.childNodesIncludeTemplate); + assert.equal(links.length, 0); + assert.equal(styles.length, 5); + assert.match(dom5.getTextContent(styles[0]), /regular-style/); + assert.match(dom5.getTextContent(styles[1]), /simple-style/); + assert.match(dom5.getTextContent(styles[2]), /import-linked-style/); + assert.match(dom5.getTextContent(styles[3]), /simple-style/); + assert.match(dom5.getTextContent(styles[4]), /import-linked-style/); + + // Verify the inlined url() values in the stylesheet are not rewritten + // to use the "imports/" prefix, since the stylesheet is inside a + // `` with an assetpath that defines the base url as + // "imports/". + assert.match( + dom5.getTextContent(styles[1]), /url\("assets\/platform\.png"\)/); + }); + + test('External style links in templates are inlined', async () => { + const {ast: doc} = + await bundle('external-in-template.html', {inlineCss: true}); + const externalStyles = dom5.queryAll( + doc, + matchers.externalStyle, + undefined, + dom5.childNodesIncludeTemplate); + assert.equal(externalStyles.length, 0); + + // There were two links to external styles in the file, now there + // should be two occurrences of inlined styles. Note they are the + // same external styles, so inlining should be happening for each + // occurrence of the external style. + const inlineStyles = dom5.queryAll( + doc, + matchers.styleMatcher, + undefined, + dom5.childNodesIncludeTemplate); + assert.equal(inlineStyles.length, 2); + assert.match(dom5.getTextContent(inlineStyles[0]), /content: 'external'/); + assert.match(dom5.getTextContent(inlineStyles[1]), /content: 'external'/); + }); + + test('Inlined styles observe containing dom-module assetpath', async () => { + const {ast: doc} = + await bundle('style-rewriting.html', {inlineCss: true}); + const style = dom5.query( + doc, matchers.styleMatcher, dom5.childNodesIncludeTemplate)!; + assert.isNotNull(style); + assert.match(dom5.getTextContent(style), /url\("styles\/unicorn.png"\)/); + }); + + test('Inlining ignores assetpath if rewriteUrlsInTemplates', async () => { + const {ast: doc} = await bundle( + 'style-rewriting.html', + {inlineCss: true, rewriteUrlsInTemplates: true}); + const style = dom5.query( + doc, matchers.styleMatcher, dom5.childNodesIncludeTemplate)!; + assert.isNotNull(style); + assert.match( + dom5.getTextContent(style), + /url\("style-rewriting\/styles\/unicorn.png"\)/); + }); + + test('Inlined styles have proper paths', async () => { + const {ast: doc} = await bundle('inline-styles.html', options); + const styles = dom5.queryAll( + doc, matchers.styleMatcher, [], dom5.childNodesIncludeTemplate); + assert.equal(styles.length, 2); + const content = dom5.getTextContent(styles[1]); + assert(content.search('imports/foo.jpg') > -1, 'path adjusted'); + assert(content.search('@apply') > -1, '@apply kept'); + }); + + test('Remote styles and media queries are preserved', async () => { + const input = 'imports/remote-stylesheet.html'; + const {ast: doc} = await bundle(input, options); + const links = dom5.queryAll(doc, matchers.externalStyle); + assert.equal(links.length, 1); + assert.match( + dom5.getAttribute(links[0]!, 'href')!, /fonts.googleapis.com/); + const styles = dom5.queryAll(doc, matchers.styleMatcher); + assert.equal(styles.length, 1); + assert.equal(dom5.getAttribute(styles[0], 'media'), '(min-width: 800px)'); + }); + + test('Inlined Polymer styles are moved into the