UNPKG

10.8 kBPlain TextView Raw
1/**
2 * @license
3 * Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
4 * This code may only be used under the BSD style license found at
5 * http://polymer.github.io/LICENSE.txt
6 * The complete set of authors may be found at
7 * http://polymer.github.io/AUTHORS.txt
8 * The complete set of contributors may be found at
9 * http://polymer.github.io/CONTRIBUTORS.txt
10 * Code distributed by Google as part of the polymer project is also
11 * subject to an additional IP rights grant found at
12 * http://polymer.github.io/PATENTS.txt
13 */
14
15import * as dom5 from 'dom5/lib/index-next';
16import * as htmlMinifier from 'html-minifier';
17import * as parse5 from 'parse5';
18
19import * as externalJs from './external-js';
20import {scriptWasSplitByHtmlSplitter} from './html-splitter';
21import {jsTransform, JsTransformOptions} from './js-transform';
22
23const p = dom5.predicates;
24
25const isJsScript = p.AND(
26 p.hasTagName('script'),
27 p.OR(
28 p.NOT(p.hasAttr('type')),
29 p.hasAttrValue('type', 'text/javascript'),
30 p.hasAttrValue('type', 'application/javascript'),
31 p.hasAttrValue('type', 'module')));
32
33const isJsScriptOrHtmlImport = p.OR(
34 isJsScript,
35 p.AND(p.hasTagName('link'), p.hasSpaceSeparatedAttrValue('rel', 'import')));
36
37/**
38 * Options for htmlTransform.
39 */
40export interface HtmlTransformOptions {
41 /**
42 * Transformations to apply to JavaScript within the HTML document.
43 */
44 js?: JsTransformOptions;
45
46 /**
47 * Whether to minify HTML.
48 */
49 minifyHtml?: boolean;
50
51 /**
52 * Whether and which Babel helpers to inject as an inline script. This is
53 * typically needed when this is the entry point HTML document and ES5
54 * compilation or AMD transform is enabled.
55 *
56 * If "none" (the default), no helpers are injected. If "full", includes the
57 * helpers needed for both ES5 compilation and the AMD transform. If "amd",
58 * includes only the helpers needed for the AMD transform.
59 */
60 injectBabelHelpers?: 'none'|'full'|'amd';
61
62 /**
63 * Whether to inject the regenerator runtime as an inline script. This is
64 * needed if you are compiling to ES5 and use async/await or generators.
65 */
66 injectRegeneratorRuntime?: boolean;
67
68 /**
69 * Whether to inject an AMD loader as an inline script. This is typically
70 * needed if ES to AMD module transformation is enabled and this is the entry
71 * point HTML document.
72 */
73 injectAmdLoader?: boolean;
74}
75
76/**
77 * Transform some HTML according to the given options.
78 */
79export function htmlTransform(
80 html: string, options: HtmlTransformOptions): string {
81 if (options.js && options.js.moduleResolution === 'node' &&
82 !options.js.filePath) {
83 throw new Error('Cannot perform node module resolution without filePath.');
84 }
85
86 const document = parse5.parse(html, {
87 locationInfo: true, // Required for removeFakeNodes.
88 });
89 removeFakeNodes(document);
90 const allScripts = [...dom5.queryAll(document, isJsScript)];
91
92 const shouldTransformEsModuleToAmd = options.js &&
93 options.js.transformModulesToAmd &&
94 // Assume that if this document has a nomodule script, the author is
95 // already handling browsers that don't support modules, and we don't
96 // need to transform them (even if the configuration was set).
97 // TODO(aomarks) Check this for HtmlSplitter case too.
98 !allScripts.some((node) => dom5.hasAttribute(node, 'nomodule'));
99
100 let wctScript, firstModuleScript;
101
102 for (const script of allScripts) {
103 const isModule = dom5.getAttribute(script, 'type') === 'module';
104 if (isModule) {
105 if (firstModuleScript === undefined) {
106 firstModuleScript = script;
107 }
108 if (shouldTransformEsModuleToAmd) {
109 transformEsModuleToAmd(script, options.js);
110 continue; // Bypass the standard jsTransform below.
111 }
112 }
113
114 const isInline = !dom5.hasAttribute(script, 'src');
115 if (isInline) {
116 // Note that scripts split by HtmlSplitter are always external, so we
117 // don't have to check for that case here.
118 const newJs = jsTransform(
119 dom5.getTextContent(script),
120 {...options.js, transformModulesToAmd: false});
121 dom5.setTextContent(script, newJs);
122
123 } else if (wctScript === undefined) {
124 const src = dom5.getAttribute(script, 'src') || '';
125 if (src.includes('web-component-tester/browser.js') ||
126 src.includes('wct-browser-legacy/browser.js') ||
127 src.includes('wct-mocha/wct-mocha.js')) {
128 wctScript = script;
129 }
130 }
131 }
132
133 if (options.injectAmdLoader && shouldTransformEsModuleToAmd &&
134 firstModuleScript !== undefined) {
135 const fragment = parse5.parseFragment('<script></script>\n');
136 dom5.setTextContent(fragment.childNodes![0], externalJs.getAmdLoader());
137 const amdLoaderScript = fragment.childNodes![0];
138
139 // Inject as late as possible (just before the first module is declared, if
140 // there is one) because there may be some UMD dependencies that we want to
141 // continue to load in global mode instead of AMD mode (which is detected by
142 // the presence of the `require` global).
143 // TODO(aomarks) If we don't define require, we can inject earlier.
144 dom5.insertBefore(
145 firstModuleScript.parentNode!, firstModuleScript, fragment);
146
147 if (wctScript !== undefined) {
148 addWctTimingHack(wctScript, amdLoaderScript);
149 }
150 }
151
152 const injectScript = (js: string) => {
153 const fragment = parse5.parseFragment('<script></script>\n');
154 dom5.setTextContent(fragment.childNodes![0], js);
155
156 const firstJsScriptOrHtmlImport =
157 dom5.query(document, isJsScriptOrHtmlImport);
158 if (firstJsScriptOrHtmlImport) {
159 dom5.insertBefore(
160 firstJsScriptOrHtmlImport.parentNode!,
161 firstJsScriptOrHtmlImport,
162 fragment);
163
164 } else {
165 const headOrDocument =
166 dom5.query(document, dom5.predicates.hasTagName('head')) || document;
167 dom5.append(headOrDocument, fragment);
168 }
169 };
170
171 let babelHelpers;
172 switch (options.injectBabelHelpers) {
173 case undefined:
174 case 'none':
175 break;
176 case 'full':
177 babelHelpers = externalJs.getBabelHelpersFull();
178 break;
179 case 'amd':
180 babelHelpers = externalJs.getBabelHelpersAmd();
181 break;
182 default:
183 const never: never = options.injectBabelHelpers;
184 throw new Error(`Unknown injectBabelHelpers value: ${never}`);
185 }
186
187 if (babelHelpers !== undefined) {
188 injectScript(babelHelpers);
189 }
190 if (options.injectRegeneratorRuntime === true) {
191 injectScript(externalJs.getRegeneratorRuntime());
192 }
193
194 html = parse5.serialize(document);
195
196 if (options.minifyHtml) {
197 html = htmlMinifier.minify(html, {
198 collapseWhitespace: true,
199 removeComments: true,
200 });
201 }
202
203 return html;
204}
205
206function transformEsModuleToAmd(
207 script: dom5.Node, jsOptions: JsTransformOptions|undefined) {
208 // We're not a module anymore.
209 dom5.removeAttribute(script, 'type');
210
211 if (scriptWasSplitByHtmlSplitter(script)) {
212 // Nothing else to do here. If we're using HtmlSplitter, the JsTransformer
213 // is responsible for doing this transformation.
214 return;
215 }
216
217 const isExternal = dom5.hasAttribute(script, 'src');
218 if (isExternal) {
219 const src = dom5.getAttribute(script, 'src');
220 dom5.removeAttribute(script, 'src');
221 dom5.setTextContent(script, `define(['${src}']);`);
222
223 } else {
224 // Transform inline scripts with the AMD Babel plugin transformer.
225 const newJs = jsTransform(dom5.getTextContent(script), {
226 ...jsOptions,
227 transformModulesToAmd: true,
228 });
229 dom5.setTextContent(script, newJs);
230 }
231}
232
233function addWctTimingHack(wctScript: dom5.Node, amdLoaderScript: dom5.Node) {
234 // This looks like a Web Component Tester script, and we have converted ES
235 // modules to AMD. Converting a module to AMD means that `DOMContentLoaded`
236 // will fire before the AMD loader resolves and executes the modules. Since
237 // WCT listens for `DOMContentLoaded`, this means test suites in modules will
238 // not have been registered by the time WCT starts running tests.
239 //
240 // To address this, we inject a block of JS that uses WCT's `waitFor` hook
241 // to defer running tests until our AMD modules have loaded. If WCT finds a
242 // `waitFor`, it passes it a callback that will run the tests, instead of
243 // running tests immediately.
244 //
245 // Note we must do this as late as possible, before the WCT script, because
246 // users may be setting their own `waitFor` that musn't clobber ours.
247 // Likewise we must call theirs if we find it.
248 dom5.insertBefore(wctScript.parentNode!, wctScript, parse5.parseFragment(`
249<script>
250 // Injected by polymer-build to defer WCT until all AMD modules are loaded.
251 (function() {
252 window.WCT = window.WCT || {};
253 var originalWaitFor = window.WCT.waitFor;
254 window.WCT.waitFor = function(cb) {
255 window._wctCallback = function() {
256 if (originalWaitFor) {
257 originalWaitFor(cb);
258 } else {
259 cb();
260 }
261 };
262 };
263 }());
264</script>
265`));
266
267 // Monkey patch `define` to keep track of loaded AMD modules. Note this
268 // assumes that all modules are registered before `DOMContentLoaded`, but
269 // that's an assumption WCT normally makes anyway. Do this right after the AMD
270 // loader is loaded, and hence before the first module is registered.
271 dom5.insertAfter(
272 amdLoaderScript.parentNode!, amdLoaderScript, parse5.parseFragment(`
273<script>
274 // Injected by polymer-build to defer WCT until all AMD modules are loaded.
275 (function() {
276 var originalDefine = window.define;
277 var moduleCount = 0;
278 window.define = function(deps, factory) {
279 moduleCount++;
280 originalDefine(deps, function() {
281 if (factory) {
282 factory.apply(undefined, arguments);
283 }
284 moduleCount--;
285 if (moduleCount === 0) {
286 window._wctCallback();
287 }
288 });
289 };
290 })();
291</script>
292`));
293}
294
295/**
296 * parse5 will inject <html>, <head>, and <body> tags if they aren't already
297 * there. Undo this so that we make fewer unnecessary transformations.
298 *
299 * Note that the given document must have been parsed with `locationInfo: true`,
300 * or else this function will always remove these tags.
301 *
302 * TODO(aomarks) Move to dom5.
303 */
304function removeFakeNodes(document: dom5.Node) {
305 const suspects = [];
306 const html =
307 (document.childNodes || []).find((child) => child.tagName === 'html');
308 if (html !== undefined) {
309 suspects.push(html);
310 for (const child of html.childNodes || []) {
311 if (child.tagName === 'head' || child.tagName === 'body') {
312 suspects.push(child);
313 }
314 }
315 }
316 for (const suspect of suspects) {
317 // No location means it wasn't in the original source.
318 if (!suspect.__location) {
319 dom5.removeNodeSaveChildren(suspect);
320 }
321 }
322}