UNPKG

11.4 kBPlain TextView Raw
1import EyeglassCompiler = require('broccoli-eyeglass');
2import Eyeglass = require('eyeglass');
3import findHost from "./findHost";
4import Funnel = require('broccoli-funnel');
5import MergeTrees = require('broccoli-merge-trees');
6import * as path from 'path';
7import * as url from 'url';
8import cloneDeep = require('lodash.clonedeep');
9import defaultsDeep = require('lodash.defaultsdeep');
10import {BroccoliSymbolicLinker} from "./broccoli-ln-s";
11import debugGenerator = require("debug");
12import { URL } from 'url';
13
14const debug = debugGenerator("ember-cli-eyeglass");
15const debugSetup = debug.extend("setup");
16const debugBuild = debug.extend("build");
17const debugCache = debug.extend("cache");
18const debugAssets = debug.extend("assets");
19
20interface EyeglassProjectInfo {
21 apps: Array<any>;
22}
23interface EyeglassAddonInfo {
24 name: string;
25 parentPath: string;
26 isApp: boolean;
27 app: any;
28}
29
30interface EyeglassAppInfo {
31 assets: BroccoliSymbolicLinker;
32 sessionCache: Map<string, string | number>;
33}
34
35interface GlobalEyeglassData {
36 infoPerAddon: WeakMap<object, EyeglassAddonInfo>;
37 infoPerApp: WeakMap<object, EyeglassAppInfo>;
38 projectInfo: EyeglassProjectInfo;
39}
40
41const g: typeof global & {EYEGLASS?: GlobalEyeglassData} = global;
42
43if (!g.EYEGLASS) {
44 g.EYEGLASS = {
45 infoPerAddon: new WeakMap(),
46 infoPerApp: new WeakMap(),
47 projectInfo: {
48 apps: []
49 }
50 }
51}
52
53const EYEGLASS_INFO_PER_ADDON = g.EYEGLASS.infoPerAddon;
54const EYEGLASS_INFO_PER_APP = g.EYEGLASS.infoPerApp;
55const APPS = g.EYEGLASS.projectInfo.apps;
56
57//eslint-disable-next-line @typescript-eslint/no-explicit-any
58function isLazyEngine(addon: any): boolean {
59 if (addon.lazyLoading === true) {
60 // pre-ember-engines 0.5.6 lazyLoading flag
61 return true;
62 }
63 if (addon.lazyLoading && addon.lazyLoading.enabled === true) {
64 return true;
65 }
66 return false;
67}
68
69//eslint-disable-next-line @typescript-eslint/no-explicit-any
70function getDefaultAssetHttpPrefix(parent: any): string {
71 // the default http prefix differs between Ember app and lazy Ember engine
72 // iterate over the parent's chain and look for a lazy engine or there are
73 // no more parents, which means we've reached the Ember app project
74 let current = parent;
75
76 while (current.parent) {
77 if (isLazyEngine(current)) {
78 // only lazy engines will inline their assets in the engines-dist folder
79 return `engines-dist/${current.name}/assets`;
80 }
81 current = current.parent;
82 }
83
84 // at this point, the highlevel container is Ember app and we should use the default 'assets' prefix
85 return 'assets';
86}
87
88/* addon.addons forms a tree(graph?) of addon objects that allow us to traverse the
89 * ember addon dependencies. However there's no path information in the addon object,
90 * but each addon object has some disconnected metadata in addon.addonPackages
91 * with the path info. Unfortunately there's no shared information that would
92 * allow us to connect which addon packages are actually which addon objects.
93 * It would be great if ember-cli didn't throw that information on the ground
94 * while building these objects. It would also be marvelous if we knew which
95 * addons came from a local addon declaration and which ones came from node
96 * modules.
97 **/
98function localEyeglassAddons(addon): Array<{path: string}> {
99 let paths = new Array<{path: string}>();
100
101 if (typeof addon.addons !== 'object' ||
102 typeof addon.addonPackages !== 'object') {
103 return paths;
104 }
105
106 let packages = Object.keys(addon.addonPackages);
107
108 for (let i = 0; i < packages.length; i++) {
109 let p = addon.addonPackages[packages[i]];
110 // Note: this will end up creating manual addons for things in node modules
111 // that are actually auto discovered, these manual modules will get deduped
112 // out. but we need to add all of them because the some eyeglass modules
113 // for addons & engines won't get autodiscovered otherwise unless the
114 // addons/engines are themselves eyeglass modules (which we don't want to require).
115 if (p.pkg.keywords.some(kw => kw == 'eyeglass-module')) {
116 paths.push({ path: p.path })
117 }
118 }
119
120 // TODO: if there's a cycle in the addon graph don't recurse.
121 for (let i = 0; i < addon.addons.length; i++) {
122 paths = paths.concat(localEyeglassAddons(addon.addons[i]));
123 }
124 return paths;
125}
126
127const EMBER_CLI_EYEGLASS = {
128 name: require("../package.json").name,
129 included(parent) {
130 this._super.included.apply(this, arguments);
131 this.initSelf();
132 },
133 initSelf() {
134 if (EYEGLASS_INFO_PER_ADDON.has(this)) return;
135 let app = findHost(this);
136 if (!app) return;
137 let isApp = (this.app === app);
138 let name = app.name;
139 if (!isApp) {
140 let thisName = typeof this.parent.name === "function" ? this.parent.name() : this.parent.name;
141 name = `${name}/${thisName}`
142 }
143 let parentPath = this.parent.root;
144 debugSetup("Initializing %s with eyeglass support for %s at %s", isApp ? "app" : "addon", name, parentPath);
145 if (isApp) {
146 APPS.push(app);
147 // we create the symlinker in persistent mode because there's not a good
148 // way yet to recreate the symlinks when sass files are cached. I would
149 // worry about it more but it seems like the dist directory is cumulative
150 // across builds anyway.
151 EYEGLASS_INFO_PER_APP.set(app, {
152 sessionCache: new Map(),
153 assets: new BroccoliSymbolicLinker({}, {annotation: app.name, persistentOutput: true})
154 });
155 }
156 let addonInfo = {isApp, name, parentPath, app};
157 EYEGLASS_INFO_PER_ADDON.set(this, addonInfo);
158 },
159 postBuild(_result) {
160 debugBuild("Build Succeeded.");
161 this._resetCaches();
162 },
163 _resetCaches() {
164 debugCache("clearing eyeglass global cache");
165 Eyeglass.resetGlobalCaches();
166 for (let app of APPS) {
167 let appInfo = EYEGLASS_INFO_PER_APP.get(app);
168 appInfo.assets.reset();
169 debugCache("clearing %d cached items from the eyeglass build cache for %s", appInfo.sessionCache.size, app.name);
170 appInfo.sessionCache.clear();
171 }
172 },
173 buildError(_error) {
174 debugBuild("Build Failed.");
175 this._resetCaches();
176 },
177 postprocessTree(type, tree) {
178 let addonInfo = EYEGLASS_INFO_PER_ADDON.get(this);
179 if (type === "all" && addonInfo.isApp) {
180 debugBuild("Merging eyeglass asset tree with the '%s' tree", type);
181 let appInfo = EYEGLASS_INFO_PER_APP.get(addonInfo.app);
182 return new MergeTrees([tree, appInfo.assets], {overwrite: true});
183 } else {
184 return tree;
185 }
186 },
187 setupPreprocessorRegistry(type, registry) {
188 let addon = this;
189
190 registry.add('css', {
191 name: 'eyeglass',
192 ext: 'scss',
193 toTree: (tree, inputPath, outputPath) => {
194 // These start with a slash and that messes things up.
195 let cssDir = outputPath.slice(1) || './';
196 let sassDir = inputPath.slice(1) || './';
197 let {app, name} = EYEGLASS_INFO_PER_ADDON.get(this);
198 let extracted = this.extractConfig(app, addon);
199 extracted.cssDir = cssDir;
200 extracted.sassDir = sassDir;
201 const config = this.setupConfig(extracted);
202 debugSetup("Broccoli Configuration for %s: %O", name, config)
203 let httpRoot = config.eyeglass && config.eyeglass.httpRoot || "/";
204 let compiler = new EyeglassCompiler(tree, config);
205 compiler.events.on("cached-asset", (absolutePathToSource, httpPathToOutput) => {
206 debugBuild("will symlink %s to %s", absolutePathToSource, httpPathToOutput);
207 try {
208 this.linkAsset(absolutePathToSource, httpRoot, httpPathToOutput);
209 } catch (e) {
210 // pass this only happens with a cache after downgrading ember-cli.
211 }
212 });
213 return compiler;
214 }
215 });
216 },
217
218 extractConfig(host, addon) {
219 const isNestedAddon = typeof addon.parent.parent === 'object';
220 // setup eyeglass for this project's configuration
221 const hostConfig = cloneDeep(host.options.eyeglass || {});
222 const addonConfig = isNestedAddon ? cloneDeep(addon.parent.options.eyeglass || {}) : {};
223 return defaultsDeep(addonConfig, hostConfig);
224 },
225
226 linkAsset(srcFile: string, httpRoot: string, destUri: string): string {
227 let rootPath = httpRoot.startsWith("/") ? httpRoot.substring(1) : httpRoot;
228 let destPath = destUri.startsWith("/") ? destUri.substring(1) : destUri;
229
230 if (process.platform === "win32") {
231 destPath = convertURLToPath(destPath);
232 rootPath = convertURLToPath(rootPath);
233 }
234
235 if (destPath.startsWith(rootPath)) {
236 destPath = path.relative(rootPath, destPath);
237 }
238 let {app} = EYEGLASS_INFO_PER_ADDON.get(this);
239 let {assets} = EYEGLASS_INFO_PER_APP.get(app);
240 debugAssets("Will link asset %s to %s to expose it at %s relative to %s",
241 srcFile, destPath, destUri, httpRoot);
242 return assets.ln_s(srcFile, destPath);
243 },
244
245 setupConfig(config: ConstructorParameters<typeof EyeglassCompiler>[1], options) {
246 let {isApp, app, parentPath} = EYEGLASS_INFO_PER_ADDON.get(this);
247 let {sessionCache} = EYEGLASS_INFO_PER_APP.get(app);
248 config.sessionCache = sessionCache;
249 config.annotation = `EyeglassCompiler(${parentPath})`;
250 if (!config.sourceFiles && !config.discover) {
251 config.sourceFiles = [isApp ? 'app.scss' : 'addon.scss'];
252 }
253 config.assets = ['public', 'app'].concat(config.assets || []);
254 config.eyeglass = config.eyeglass || {}
255 // XXX We don't set the root anywhere but I'm not sure what might break if we do.
256 // config.eyeglass.root = parentPath;
257 config.eyeglass.httpRoot = config.eyeglass.httpRoot || config["httpRoot"];
258 if (config.persistentCache) {
259 let cacheDir = parentPath.replace(/\//g, "$");
260 config.persistentCache += `/${cacheDir}`;
261 }
262
263 config.assetsHttpPrefix = config.assetsHttpPrefix || getDefaultAssetHttpPrefix(this.parent);
264
265 if (config.eyeglass.modules) {
266 config.eyeglass.modules =
267 config.eyeglass.modules.concat(localEyeglassAddons(this.parent));
268 } else {
269 config.eyeglass.modules = localEyeglassAddons(this.parent);
270 }
271 let originalConfigureEyeglass = config.configureEyeglass;
272 config.configureEyeglass = (eyeglass, sass, details) => {
273 eyeglass.assets.installer((file, uri, fallbackInstaller, cb) => {
274 try {
275 cb(null, this.linkAsset(file, eyeglass.options.eyeglass.httpRoot || "/", uri))
276 } catch (e) {
277 cb(e);
278 }
279 });
280 if (originalConfigureEyeglass) {
281 originalConfigureEyeglass(eyeglass, sass, details);
282 }
283 };
284
285 // If building an app, rename app.css to <project>.css per Ember conventions.
286 // Otherwise, we're building an addon, so rename addon.css to <name-of-addon>.css.
287 let originalGenerator = config.optionsGenerator;
288 config.optionsGenerator = (sassFile, cssFile, sassOptions, compilationCallback) => {
289 if (isApp) {
290 cssFile = cssFile.replace(/app\.css$/, `${this.app.name}.css`);
291 } else {
292 cssFile = cssFile.replace(/addon\.css$/, `${this.parent.name}.css`);
293 }
294
295 if (originalGenerator) {
296 originalGenerator(sassFile, cssFile, sassOptions, compilationCallback);
297 } else {
298 compilationCallback(cssFile, sassOptions);
299 }
300 };
301
302 return config;
303 }
304};
305
306function convertURLToPath(fragment: string): string {
307 return (new URL(`file://${fragment}`)).pathname;
308}
309export = EMBER_CLI_EYEGLASS;
\No newline at end of file