UNPKG

9.4 kBPlain TextView Raw
1/**
2 * @license
3 * Copyright (c) 2016 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 assert from 'assert';
16import * as dom5 from 'dom5/lib/index-next';
17import * as parse5 from 'parse5';
18import * as osPath from 'path';
19import {Transform} from 'stream';
20import File = require('vinyl');
21import {AsyncTransformStream, getFileContents} from './streams';
22
23const pred = dom5.predicates;
24
25const extensionsForType: {[mimetype: string]: string} = {
26 'text/ecmascript-6': 'js',
27 'application/javascript': 'js',
28 'text/javascript': 'js',
29 'application/x-typescript': 'ts',
30 'text/x-typescript': 'ts',
31 'module': 'js',
32};
33
34/**
35 * HTMLSplitter represents the shared state of files as they are passed through
36 * a splitting stream and then a rejoining stream. Creating a new instance of
37 * HTMLSplitter and adding its streams to the build pipeline is the
38 * supported user interface for splitting out and rejoining inlined CSS & JS in
39 * the build process.
40 */
41export class HtmlSplitter {
42 private _splitFiles: Map<string, SplitFile> = new Map();
43 private _parts: Map<string, SplitFile> = new Map();
44
45 /**
46 * Returns a new `Transform` stream that splits inline script and styles into
47 * new, separate files that are passed out of the stream.
48 */
49 split(): Transform {
50 return new HtmlSplitTransform(this);
51 }
52
53 /**
54 * Returns a new `Transform` stream that rejoins inline scripts and styles
55 * that were originally split from this `HTMLSplitter`'s `split()` back into
56 * their parent HTML files.
57 */
58 rejoin(): Transform {
59 return new HtmlRejoinTransform(this);
60 }
61
62 isSplitFile(parentPath: string): boolean {
63 return this._splitFiles.has(parentPath);
64 }
65
66 getSplitFile(parentPath: string): SplitFile {
67 // TODO(justinfagnani): rewrite so that processing a parent file twice
68 // throws to protect against bad configurations of multiple streams that
69 // contain the same file multiple times.
70 let splitFile = this._splitFiles.get(parentPath);
71 if (!splitFile) {
72 splitFile = new SplitFile(parentPath);
73 this._splitFiles.set(parentPath, splitFile);
74 }
75 return splitFile;
76 }
77
78 addSplitPath(parentPath: string, childPath: string): void {
79 const splitFile = this.getSplitFile(parentPath);
80 splitFile.addPartPath(childPath);
81 this._parts.set(childPath, splitFile);
82 }
83
84 getParentFile(childPath: string): SplitFile|undefined {
85 return this._parts.get(childPath);
86 }
87}
88
89const htmlSplitterAttribute = 'html-splitter';
90
91/**
92 * Returns whether the given script tag was an inline script that was split out
93 * into a fake file by HtmlSplitter.
94 */
95export function scriptWasSplitByHtmlSplitter(script: dom5.Node): boolean {
96 return dom5.hasAttribute(script, htmlSplitterAttribute);
97}
98
99export type HtmlSplitterFile = File&{
100 fromHtmlSplitter?: true;
101 isModule?: boolean
102};
103
104/**
105 * Return whether the given Vinyl file was created by the HtmlSplitter from an
106 * HTML document script tag.
107 */
108export function isHtmlSplitterFile(file: File): file is HtmlSplitterFile {
109 return file.fromHtmlSplitter === true;
110}
111
112/**
113 * Represents a file that is split into multiple files.
114 */
115export class SplitFile {
116 path: string;
117 parts: Map<string, string|null> = new Map();
118 outstandingPartCount = 0;
119 vinylFile: File|null = null;
120
121 constructor(path: string) {
122 this.path = path;
123 }
124
125 addPartPath(path: string): void {
126 this.parts.set(path, null);
127 this.outstandingPartCount++;
128 }
129
130 setPartContent(path: string, content: string): void {
131 assert(
132 this.parts.get(path) !== undefined,
133 `Trying to save unexpected file part "${path}".`);
134 assert(
135 this.parts.get(path) === null,
136 `Trying to save already-saved file part "${path}".`);
137 assert(
138 this.outstandingPartCount > 0,
139 `Trying to save valid file part "${path}", ` +
140 `but somehow no file parts are outstanding.`);
141 this.parts.set(path, content);
142 this.outstandingPartCount--;
143 }
144
145 get isComplete(): boolean {
146 return this.outstandingPartCount === 0 && this.vinylFile != null;
147 }
148}
149
150/**
151 * Splits HTML files, extracting scripts and styles into separate File objects.
152 */
153class HtmlSplitTransform extends AsyncTransformStream<File, File> {
154 _state: HtmlSplitter;
155
156 constructor(splitter: HtmlSplitter) {
157 super({objectMode: true});
158 this._state = splitter;
159 }
160
161 protected async *
162 _transformIter(files: AsyncIterable<File>): AsyncIterable<File> {
163 for await (const file of files) {
164 const filePath = osPath.normalize(file.path);
165 if (!(file.contents && filePath.endsWith('.html'))) {
166 yield file;
167 continue;
168 }
169 const contents = await getFileContents(file);
170 const doc = parse5.parse(contents, {locationInfo: true});
171 dom5.removeFakeRootElements(doc);
172 const scriptTags = [...dom5.queryAll(doc, pred.hasTagName('script'))];
173 for (let i = 0; i < scriptTags.length; i++) {
174 const scriptTag = scriptTags[i];
175 const source = dom5.getTextContent(scriptTag);
176 const typeAttribute =
177 dom5.getAttribute(scriptTag, 'type') || 'application/javascript';
178 const extension = extensionsForType[typeAttribute];
179 // If we don't recognize the script type attribute, don't split
180 // out.
181 if (!extension) {
182 continue;
183 }
184
185 const isInline = !dom5.hasAttribute(scriptTag, 'src');
186
187 if (isInline) {
188 const childFilename =
189 `${osPath.basename(filePath)}_script_${i}.${extension}`;
190 const childPath =
191 osPath.join(osPath.dirname(filePath), childFilename);
192 scriptTag.childNodes = [];
193 dom5.setAttribute(scriptTag, 'src', childFilename);
194 dom5.setAttribute(scriptTag, htmlSplitterAttribute, '');
195 const scriptFile: HtmlSplitterFile = new File({
196 cwd: file.cwd,
197 base: file.base,
198 path: childPath,
199 contents: Buffer.from(source),
200 });
201 scriptFile.fromHtmlSplitter = true;
202 scriptFile.isModule = typeAttribute === 'module';
203 this._state.addSplitPath(filePath, childPath);
204 this.push(scriptFile);
205 }
206 }
207
208 const splitContents = parse5.serialize(doc);
209 const newFile = new File({
210 cwd: file.cwd,
211 base: file.base,
212 path: filePath,
213 contents: Buffer.from(splitContents),
214 });
215 yield newFile;
216 }
217 }
218}
219
220
221/**
222 * Joins HTML files originally split by `Splitter`, based on the relationships
223 * stored in its HTMLSplitter state.
224 */
225class HtmlRejoinTransform extends AsyncTransformStream<File, File> {
226 static isExternalScript =
227 pred.AND(pred.hasTagName('script'), pred.hasAttr('src'));
228
229 _state: HtmlSplitter;
230
231 constructor(splitter: HtmlSplitter) {
232 super({objectMode: true});
233 this._state = splitter;
234 }
235
236 protected async *
237 _transformIter(files: AsyncIterable<File>): AsyncIterable<File> {
238 for await (const file of files) {
239 const filePath = osPath.normalize(file.path);
240 if (this._state.isSplitFile(filePath)) {
241 // this is a parent file
242 const splitFile = this._state.getSplitFile(filePath);
243 splitFile.vinylFile = file;
244 if (splitFile.isComplete) {
245 yield await this._rejoin(splitFile);
246 } else {
247 splitFile.vinylFile = file;
248 }
249 } else {
250 const parentFile = this._state.getParentFile(filePath);
251 if (parentFile) {
252 // this is a child file
253 parentFile.setPartContent(filePath, file.contents!.toString());
254 if (parentFile.isComplete) {
255 yield await this._rejoin(parentFile);
256 }
257 } else {
258 yield file;
259 }
260 }
261 }
262 }
263
264 async _rejoin(splitFile: SplitFile) {
265 const file = splitFile.vinylFile;
266 if (file == null) {
267 throw new Error(`Internal error: no vinylFile found for splitfile: ${
268 splitFile.path}`);
269 }
270 const filePath = osPath.normalize(file.path);
271 const contents = await getFileContents(file);
272 const doc = parse5.parse(contents, {locationInfo: true});
273 dom5.removeFakeRootElements(doc);
274 const scriptTags = dom5.queryAll(doc, HtmlRejoinTransform.isExternalScript);
275
276 for (const scriptTag of scriptTags) {
277 const srcAttribute = dom5.getAttribute(scriptTag, 'src')!;
278 const scriptPath =
279 osPath.join(osPath.dirname(splitFile.path), srcAttribute);
280 const scriptSource = splitFile.parts.get(scriptPath);
281 if (scriptSource != null) {
282 dom5.setTextContent(scriptTag, scriptSource);
283 dom5.removeAttribute(scriptTag, 'src');
284 dom5.removeAttribute(scriptTag, htmlSplitterAttribute);
285 }
286 }
287
288 const joinedContents = parse5.serialize(doc);
289
290 return new File({
291 cwd: file.cwd,
292 base: file.base,
293 path: filePath,
294 contents: Buffer.from(joinedContents),
295 });
296 }
297}