UNPKG

7.88 kBPlain TextView Raw
1import Plugin, { Tree } from 'broccoli-plugin';
2import { join, extname } from 'path';
3import walkSync, { WalkSyncEntry } from 'walk-sync';
4import { unlinkSync, rmdirSync, mkdirSync, readFileSync, existsSync, writeFileSync, removeSync, readdirSync } from 'fs-extra';
5import FSTree from 'fs-tree-diff';
6import symlinkOrCopy from 'symlink-or-copy';
7import uniqBy from 'lodash/uniqBy';
8import { insertBefore} from './source-map-url';
9
10/*
11 This is a fairly specialized broccoli transform that we use to get the output
12 of our webpack build added to the ember app. Mostly it's needed because we're
13 forced to run quite late and use the postprocessTree hook, rather than nicely
14 emit our content as part of treeForVendor, etc, which would be easier but
15 doesn't work because of whack data dependencies in new versions of ember-cli's
16 broccoli graph.
17*/
18
19export interface AppendOptions {
20 // map from a directory in the appendedTree (like `entrypoints/app`) to a map
21 // keyed by file type (extension) containing file paths that may exists in the
22 // upstreamTree (like `assets/vendor.js`). Appends the JS/CSS files in the
23 // directory to that file, when it exists.
24 mappings: Map<string, Map<string, string>>;
25
26 // map from a directory in the appendedTree (like `lazy`) to a directory where
27 // we will output those files in the output (like `assets`).
28 passthrough: Map<string, string>;
29}
30
31export default class Append extends Plugin {
32 private previousUpstreamTree = new FSTree();
33 private previousAppendedTree = new FSTree();
34 private mappings: Map<string, Map<string, string>>;
35 private reverseMappings: Map<string, string>;
36 private passthrough: Map<string, string>;
37
38 constructor(upstreamTree: Tree, appendedTree: Tree, options: AppendOptions) {
39 super([upstreamTree, appendedTree], {
40 annotation: 'ember-auto-import-analyzer',
41 persistentOutput: true
42 });
43
44 // mappings maps entry points to maps that map file types to output files.
45 // reverseMappings maps output files back to entry points.
46 let reverseMappings = new Map();
47 for (let [key, map] of options.mappings.entries( )) {
48 for (let value of map.values( )) {
49 reverseMappings.set(value, key);
50 }
51 }
52
53 this.mappings = options.mappings;
54 this.reverseMappings = reverseMappings;
55 this.passthrough = options.passthrough;
56 }
57
58 private get upstreamDir() {
59 return this.inputPaths[0];
60 }
61
62 private get appendedDir() {
63 return this.inputPaths[1];
64 }
65
66 // returns the set of output files that should change based on changes to the
67 // appendedTree.
68 private diffAppendedTree() {
69 let changed = new Set();
70 let { patchset, passthroughEntries } = this.appendedPatchset();
71 for (let [, relativePath] of patchset) {
72 let match = findByPrefix(relativePath, this.mappings);
73 if (match) {
74 let ext = extname(relativePath).slice(1);
75 if (match.mapsTo.has(ext)) {
76 changed.add(match.mapsTo.get(ext));
77 }
78 }
79 }
80 return { needsUpdate: changed, passthroughEntries };
81 }
82
83 build() {
84 // First note which output files should change due to changes in the
85 // appendedTree
86 let { needsUpdate, passthroughEntries } = this.diffAppendedTree();
87
88 // Then process all changes in the upstreamTree
89 for (let [operation, relativePath, entry] of this.upstreamPatchset(passthroughEntries)) {
90 let outputPath = join(this.outputPath, relativePath);
91 switch (operation) {
92 case 'unlink':
93 unlinkSync(outputPath);
94 break;
95 case 'rmdir':
96 rmdirSync(outputPath);
97 break;
98 case 'mkdir':
99 mkdirSync(outputPath);
100 break;
101 case 'change':
102 removeSync(outputPath);
103 // deliberate fallthrough
104 case 'create':
105 if (this.reverseMappings.has(relativePath)) {
106 // this is where we see the upstream original file being created or
107 // modified. We should always generate the complete appended file here.
108 this.handleAppend(relativePath);
109 // it no longer needs update once we've handled it here
110 needsUpdate.delete(relativePath);
111 } else {
112 if (isPassthrough(entry)) {
113 symlinkOrCopy.sync(join(this.appendedDir, entry.originalRelativePath), outputPath);
114 } else {
115 symlinkOrCopy.sync(join(this.upstreamDir, relativePath), outputPath);
116 }
117 }
118 }
119 }
120
121 // finally, any remaining things in `needsUpdate` are cases where the
122 // appendedTree changed but the corresponding file in the upstreamTree
123 // didn't. Those needs to get handled here.
124 for (let relativePath of needsUpdate.values()) {
125 this.handleAppend(relativePath);
126 }
127 }
128
129 private upstreamPatchset(passthroughEntries: AugmentedWalkSyncEntry[]) {
130 let input: AugmentedWalkSyncEntry[] = walkSync.entries(this.upstreamDir).concat(passthroughEntries);
131
132 // FSTree requires the entries to be sorted and uniq
133 input.sort(compareByRelativePath);
134 input = uniqBy(input, e => (e as any).relativePath);
135
136 let previous = this.previousUpstreamTree;
137 let next = (this.previousUpstreamTree = FSTree.fromEntries(input));
138 return previous.calculatePatch(next) as [ string, string, AugmentedWalkSyncEntry ][];
139 }
140
141 private appendedPatchset() {
142 let input = walkSync.entries(this.appendedDir);
143 let passthroughEntries = input
144 .map(e => {
145 let match = findByPrefix(e.relativePath, this.passthrough);
146 if (match) {
147 let o = Object.create(e);
148 o.relativePath = e.relativePath.replace(new RegExp('^' + match.prefix), match.mapsTo);
149 o.isPassthrough = true;
150 o.originalRelativePath = e.relativePath;
151 return o;
152 }
153 }).filter(e => e && e.relativePath !== './') as AugmentedWalkSyncEntry[];
154
155 let previous = this.previousAppendedTree;
156 let next = (this.previousAppendedTree = FSTree.fromEntries(input));
157 return { patchset: previous.calculatePatch(next), passthroughEntries };
158 }
159
160 private handleAppend(relativePath: string) {
161 let upstreamPath = join(this.upstreamDir, relativePath);
162 let outputPath = join(this.outputPath, relativePath);
163 let ext = extname(relativePath);
164
165 if (!existsSync(upstreamPath)) {
166 removeSync(outputPath);
167 return;
168 }
169
170 let sourceDir = join(this.appendedDir, this.reverseMappings.get(relativePath)!);
171 if (!existsSync(sourceDir)) {
172 symlinkOrCopy.sync(upstreamPath, outputPath);
173 return;
174 }
175
176 let appendedContent = readdirSync(sourceDir).map(name => {
177 if (name.endsWith(ext)) {
178 return readFileSync(join(sourceDir, name), 'utf8');
179 }
180 }).filter(Boolean).join(";\n");
181 let upstreamContent = readFileSync(upstreamPath, 'utf8');
182 if (appendedContent.length > 0) {
183 upstreamContent = insertBefore(upstreamContent, ";\n" + appendedContent);
184 }
185 writeFileSync(outputPath, upstreamContent, 'utf8');
186 }
187}
188
189function compareByRelativePath(entryA: WalkSyncEntry, entryB: WalkSyncEntry) {
190 let pathA = entryA.relativePath;
191 let pathB = entryB.relativePath;
192
193 if (pathA < pathB) {
194 return -1;
195 } else if (pathA > pathB) {
196 return 1;
197 }
198 return 0;
199}
200
201function isPassthrough(entry: AugmentedWalkSyncEntry): entry is PassthroughEntry {
202 return (entry as any).isPassthrough;
203}
204
205interface PassthroughEntry extends WalkSyncEntry {
206 isPassthrough: true;
207 originalRelativePath: string;
208}
209
210type AugmentedWalkSyncEntry = WalkSyncEntry | PassthroughEntry;
211
212function findByPrefix<T>(path: string, map: Map<string, T>) {
213 let parts = path.split('/');
214 for (let i = 1; i < parts.length; i++) {
215 let candidate = parts.slice(0, i).join('/');
216 if (map.has(candidate)) {
217 return {
218 prefix: candidate,
219 mapsTo: map.get(candidate)!
220 };
221 }
222 }
223}