UNPKG

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