1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 | import * as assert from 'assert';
|
16 | import * as dom5 from 'dom5/lib/index-next';
|
17 | import * as parse5 from 'parse5';
|
18 | import * as osPath from 'path';
|
19 | import {Transform} from 'stream';
|
20 | import File = require('vinyl');
|
21 | import {AsyncTransformStream, getFileContents} from './streams';
|
22 |
|
23 | const pred = dom5.predicates;
|
24 |
|
25 | const 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 |
|
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 | export class HtmlSplitter {
|
42 | private _splitFiles: Map<string, SplitFile> = new Map();
|
43 | private _parts: Map<string, SplitFile> = new Map();
|
44 |
|
45 | |
46 |
|
47 |
|
48 |
|
49 | split(): Transform {
|
50 | return new HtmlSplitTransform(this);
|
51 | }
|
52 |
|
53 | |
54 |
|
55 |
|
56 |
|
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 |
|
68 |
|
69 |
|
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 |
|
89 | const htmlSplitterAttribute = 'html-splitter';
|
90 |
|
91 |
|
92 |
|
93 |
|
94 |
|
95 | export function scriptWasSplitByHtmlSplitter(script: dom5.Node): boolean {
|
96 | return dom5.hasAttribute(script, htmlSplitterAttribute);
|
97 | }
|
98 |
|
99 | export type HtmlSplitterFile = File&{
|
100 | fromHtmlSplitter?: true;
|
101 | isModule?: boolean
|
102 | };
|
103 |
|
104 |
|
105 |
|
106 |
|
107 |
|
108 | export function isHtmlSplitterFile(file: File): file is HtmlSplitterFile {
|
109 | return file.fromHtmlSplitter === true;
|
110 | }
|
111 |
|
112 |
|
113 |
|
114 |
|
115 | export 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 |
|
152 |
|
153 | class 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 |
|
180 |
|
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 |
|
223 |
|
224 |
|
225 | class 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 |
|
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 |
|
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 | }
|