UNPKG

19.6 kBJavaScriptView Raw
1/*
2Copyright 2013 Yahoo! Inc. All rights reserved.
3Licensed under the BSD License.
4http://yuilibrary.com/license/
5*/
6
7YUI.add('gallery-pathogen-encoder', function (Y, NAME) {
8
9var resolve = Y.Loader.prototype.resolve,
10
11 NAMESPACE = 'p/',
12 GROUP_DELIM = ';',
13 SUB_GROUP_DELIM = '+',
14 MODULE_DELIM = ',',
15
16 FILTER_RE = /-(min|debug).js/,
17 EXTENSION_RE = /\.(?:js|css)$/,
18 GALLERY_RE = /^(?:yui:)?gallery-([^\/]+)/,
19 TYPES = { js: true, css: true },
20
21 customComboBase,
22 maxURLLength,
23 galleryVersion;
24
25/**
26Build each combo url from the bottom up. There's probably room for optimization
27here, but let's keep it simple for now.
28@method buildCombo
29@param {Array} groups Grouped module meta.
30@param {String} comboBase The base of the combo url.
31@param {String} comboTail The tail of the combo url (e.g. .debug.js).
32@return {String} A combo url.
33*/
34Y.Loader.prototype.buildCombo = function (groups, comboBase, comboTail) {
35 var comboUrl = comboBase,
36 currLen = comboBase.length + comboTail.length,
37 currDelim,
38 currKey,
39 prepend,
40 modules,
41 token,
42 group,
43 len,
44 i;
45
46 for (i = 0, len = groups.length; i < len; i += 1) {
47 group = groups[i];
48 currDelim = comboUrl === comboBase ? '' : GROUP_DELIM;
49 currKey = group.key;
50 modules = group.modules;
51
52 while (modules.length) {
53 prepend = currDelim + currKey;
54 prepend = prepend ? prepend + SUB_GROUP_DELIM : MODULE_DELIM;
55
56 // Modules with custom paths have `group.key` set to the same value
57 // as their module name. These are treated as their own module
58 // group.
59 if (modules.length === 1 && currKey === modules[0]) {
60 prepend = currDelim;
61 }
62
63 token = prepend + modules[0];
64
65 if (currLen + token.length < maxURLLength) {
66 comboUrl += token;
67 currLen += token.length;
68 modules.shift();
69 } else {
70 return comboUrl + comboTail;
71 }
72
73 currDelim = currKey = '';
74 }
75 }
76
77 comboUrl += comboTail;
78
79 // If no modules were encoded in the combo url.
80 if (comboUrl.length === comboBase.length + comboTail.length) {
81 comboUrl = null;
82 }
83
84 return comboUrl;
85};
86
87/**
88Aggregate modules into groups with unique keys. The key is "$name+$version" for
89core and gallery groups, and just "$root" for all other groups.
90@method aggregateGroups
91@param {Array} modules A list of module meta.
92@return {Object} Aggregated groups of module meta.
93*/
94Y.Loader.prototype.aggregateGroups = function (modules) {
95 var source = {},
96 galleryMatch,
97 compressed,
98 prefixTree,
99 meta,
100 name,
101 mod,
102 key,
103 len,
104 i;
105
106 // Segment the modules for efficient combo encoding.
107 for (i = 0, len = modules.length; i < len; i += 1) {
108 mod = modules[i];
109 name = mod.name;
110
111 // Skip modules that should be loaded singly. This is kind of confusing
112 // because it mimics the behavior of the loader (also confusing):
113 // https://github.com/ekashida/yui3/blob/632167a36d57da7a884aacf0f4488dd5b8619c7c/src/loader/js/loader.js#L2563
114 meta = this.groups && this.groups[mod.group];
115 if (meta) {
116 if (!meta.combine || mod.fullpath) {
117 continue;
118 }
119 } else if (!this.combine) {
120 continue;
121 }
122 if (!mod.combine && mod.ext) {
123 continue;
124 }
125
126 // YUI core modules => core group
127 if (!mod.group) {
128 key = 'c' + SUB_GROUP_DELIM + YUI.version;
129 }
130 // YUI gallery modules => gallery group
131 else if (mod.group === 'gallery') {
132 if (!galleryVersion) {
133 galleryMatch = GALLERY_RE.exec(this.groups.gallery.root);
134 galleryVersion = galleryMatch && galleryMatch[1];
135 }
136 name = name.split('gallery-').pop(); // remove prefix
137 key = 'g' + SUB_GROUP_DELIM + galleryVersion;
138 }
139 // If the module was built the YUI way, then we segment these modules
140 // into the `shifter` group.
141 else if (mod.path.indexOf(name + '/' + name) === 0) {
142 key = meta.root;
143
144 // Trim '/' from both ends.
145 if (key[0] === '/') {
146 key = key.slice(1);
147 }
148 if (key[key.length - 1] === '/') {
149 key = key.slice(0, -1);
150 }
151
152 key = 's' + SUB_GROUP_DELIM + key;
153 }
154 // If the path does not follow the YUI build convention, then we
155 // add them to the prefix tree and subsequently segment these modules
156 // into the `path` group.
157 else {
158 // remove file extension
159 name = mod.path.split(EXTENSION_RE).shift();
160
161 if (meta && meta.root) {
162 name = meta.root + name;
163 }
164
165 if (name[0] === '/') {
166 name = name.slice(1);
167 }
168
169 // If fullpath compression is enabled, add this module's fullpath
170 // to the prefix tree for later compression
171 if (Y.config.fullpathCompression) {
172 prefixTree = prefixTree || new PrefixTree({
173 moduleDelim: MODULE_DELIM,
174 subgroupDelim: SUB_GROUP_DELIM,
175 groupDelim: GROUP_DELIM
176 });
177 prefixTree.add(name);
178 continue;
179 }
180
181 key = name;
182 }
183
184 source[key] = source[key] || [];
185 source[key].push(name);
186
187 // If fallback feature is enabled, record the full module name as seen
188 if (Y.config.customComboFallback) {
189 if (mod.group === 'gallery') {
190 name = 'gallery-' + name;
191 }
192 this.pathogenSeen[name] = true;
193 }
194 }
195
196 if (prefixTree) {
197 compressed = prefixTree.compress();
198 for (i = 0, len = compressed.length; i < len; i += 1) {
199 key = compressed[i].root;
200 source[key] = source[key] || [];
201 source[key].push(compressed[i].name);
202 }
203
204 // clean up
205 prefixTree.destroy();
206 prefixTree = null;
207 }
208
209 return source;
210};
211
212/**
213A prefix tree data structure for file paths. Can optimally compress itself into
214a serialized representation of a pathogen-encoded url.
215
216@class PrefixTree
217@constructor
218**/
219function PrefixTree (config) {
220 this.tree = {
221 weight: Number.MAX_VALUE,
222 path: '/',
223 children: {}
224 };
225
226 this.moduleDelimLen = config.moduleDelim && config.moduleDelim.length || 0;
227 this.subgroupDelimLen = config.subgroupDelim && config.subgroupDelim.length || 0;
228 this.groupDelimLen = config.groupDelim && config.groupDelim.length || 0;
229}
230
231PrefixTree.prototype = {
232
233 /**
234 Adds a path to the prefix tree instance. Calculates the weight of each node
235 as paths are added. The weight of a node represents the sum of:
236 1) the string length of the path represented by the root and the node
237 2) the string lengths of the paths represented by the node and each leaf node
238
239 For example, given the set of full paths:
240 some/cool/path
241 some/cool/other/path
242 some/awesome/path
243
244 The tree looks like:
245 some --> cool --> path
246 \ \--> other --> path
247 \--> awesome --> path
248
249 The weight of the `some` node is:
250 'some+cool/path,cool/other/path,awesome/path'.length
251
252 The weight of the `cool` node is:
253 'some/cool+path,other/path'.length
254
255 The weight of the `awesome` node is:
256 'some/awesome+path'.length
257
258 @method add
259 @param {String} fullpath An absolute path
260 **/
261 add: function (fullpath) {
262 var currentNode = this.tree,
263 remaining = fullpath.split('/'),
264 traversed = [],
265 remainingPath,
266 traversedPath,
267 child,
268 part;
269
270 while (remaining.length) {
271 part = remaining.shift();
272 child = currentNode.children[part];
273
274 traversed.push(part);
275 remainingPath = remaining.join('/');
276
277 if (!child) {
278 traversedPath = traversed.join('/');
279
280 child = currentNode.children[part] = {
281 path: traversedPath,
282 weight: 0
283 };
284
285 // If not leaf node
286 if (remainingPath) {
287 // some/cool/path => some+cool/path
288 child.weight = traversedPath.length // 'some'.length
289 + this.subgroupDelimLen // '+'.length
290 + remainingPath.length; // 'cool/path'.length
291
292 child.children = {};
293 } else {
294 // Guarantee that leaf nodes will never be roots
295 child.weight = Number.MAX_VALUE;
296 }
297 } else {
298 // add 'some/awesome/path' to existing 'some+cool/path'
299 // => some+cool/path,awesome/path
300 child.weight += this.moduleDelimLen // ','.length
301 + remainingPath.length; // 'awesome/path'.length
302 }
303
304 // bubble down
305 currentNode = child;
306 }
307
308 Y.log('Added ' + currentNode.path, 'debug', 'PrefixTree');
309 },
310
311 /**
312 Compresses the prefix tree. Uses DFS to find the optimal set of root paths
313 and their corresponding relative paths that results in the shortest
314 serialized length.
315
316 @method compress
317 @return {Array} compressed An array of root path and relative path pairs
318 **/
319 compress: function () {
320 var process = [],
321 compressed = [],
322 children,
323 root_re,
324 leaves,
325 total,
326 child,
327 node,
328 key,
329 i;
330
331 // Start with the root node's children
332 for (key in this.tree.children) {
333 if (this.tree.children.hasOwnProperty(key)) {
334 process.push(this.tree.children[key]);
335 }
336 }
337
338 Y.log(this.stringify(this.tree), 'debug', 'PrefixTree');
339
340 while (process.length) {
341 total = 0;
342 children = [];
343
344 node = process.pop();
345
346 // Find the total resulting weight if we use the children of this
347 // node as roots
348 for (key in node.children) {
349 if (node.children.hasOwnProperty(key)) {
350 child = node.children[key];
351 total += child.weight;
352 children.push(child);
353 }
354 }
355
356 // Account for the group delimiter lengths
357 total += children.length - 1;
358
359 Y.log('Weight of the current root "' + node.path + '": ' + node.weight, 'debug', 'PrefixTree');
360 Y.log('Combined weight of child roots: ' + total, 'debug', 'PrefixTree');
361
362 if (node.weight <= total) {
363 // If the weigth of this node is less than or equal to the
364 // total weight of its child nodes combined, it means that
365 // we'll get better compression by using this node as a root
366 Y.log('Established root: "' + node.path + '"', 'debug', 'PrefixTree');
367
368 root_re = new RegExp('^' + node.path + '/');
369
370 // Now that we've decided to use this node (root path), we can
371 // determine what the module names (relative paths) should be
372 leaves = this.getLeafNodes(node);
373 for (i = 0; i < leaves.length; i += 1) {
374 compressed.push({
375 // module name = full path - root
376 name: leaves[i].path.replace(root_re, ''),
377 root: node.path
378 });
379 }
380 } else {
381 // If the weight of this node is greater than the weight of its
382 // child nodes combined, it means that we'll get better
383 // compression by using the set of root paths represented by
384 // the child nodes
385 Y.log('Scheduling ' + children.length + ' child(ren) of "' + node.path + '" for further processing...', 'debug', 'PrefixTree');
386 process = process.concat(children);
387 }
388 }
389
390 Y.log(this.stringify(compressed), 'debug', 'PrefixTree');
391
392 return compressed;
393 },
394
395 /**
396 Finds all the leaf nodes of a given node
397 @method getLeafNodes
398 @param {Object} tree A node in the prefix tree
399 @return {Array} All leaf nodes of a particular node
400 **/
401 getLeafNodes: function (tree) {
402 var leaves = [],
403 key;
404
405 // base case
406 if (!tree.children) {
407 leaves.push(tree);
408 return leaves;
409 }
410
411 for (key in tree.children) {
412 if (tree.children.hasOwnProperty(key)) {
413 leaves = leaves.concat(
414 this.getLeafNodes(
415 tree.children[key]
416 )
417 );
418 }
419 }
420
421 return leaves;
422 },
423
424 /**
425 Destroys the instance to free up memory
426 @method destroy
427 **/
428 destroy: function () {
429 this.tree = null;
430 },
431
432 stringify: function (thing) {
433 return JSON && JSON.stringify(thing, null, 4);
434 }
435
436};
437
438/**
439Sort the aggregated groups, and the modules within them. Minimizes cache misses
440in Yahoo's infrastructure by encoding predictable combo urls across browsers
441since iterating over an object does not guarantee order.
442@method sortAggregatedGroups
443@param {Object} groups Aggregated groups.
444@return {Array} Sorted groups.
445**/
446Y.Loader.prototype.sortAggregatedGroups = function (groups) {
447 var sorted = [],
448 key,
449 len,
450 i;
451
452 for (key in groups) {
453 if (groups.hasOwnProperty(key)) {
454 sorted.push({
455 key: key,
456 modules: groups[key]
457 });
458 }
459 }
460
461 // Sort the groups.
462 sorted.sort(function (a, b) {
463 return a.key > b.key;
464 });
465
466 // Sort the modules.
467 for (i = 0, len = sorted.length; i < len; i += 1) {
468 sorted[i].modules.sort();
469 }
470
471 return sorted;
472};
473
474/**
475Build each combo url from the bottom up. There's probably room for optimization
476here, but let's keep it simple for now.
477@method customResolve
478@param {Array} modules A list of module meta.
479@param {String} type Either `js` or `css`.
480@return {String} Combo url.
481*/
482Y.Loader.prototype.customResolve = function (modules, type) {
483 var source = this.aggregateGroups(modules),
484 groups = this.sortAggregatedGroups(source),
485 comboUrls = [],
486 comboTail,
487 filter,
488 match,
489 url;
490
491 // Determine the combo tail (e.g., '.debug.js'). Assumption: `filter` is
492 // global to the resolve() and should match the filter on loader.
493 if (!filter) {
494 match = FILTER_RE.exec(Y.config.loaderPath);
495 filter = match && match[1] || 'raw';
496 filter = (type === 'css' && filter === 'debug') ? 'raw' : 'min';
497 comboTail = filter === 'min' ? '' : '.' + filter;
498 comboTail = comboTail + '.' + type;
499 }
500
501 url = this.buildCombo(groups, customComboBase, comboTail);
502 while (url) {
503 comboUrls.push(url);
504 url = this.buildCombo(groups, customComboBase, comboTail);
505 }
506
507 return comboUrls;
508};
509
510/**
511Determines whether or not we should fallback to default combo urls by checking
512to see if Loader re-requests any module that we've already seen.
513@method shouldFallback
514@param {Object} resolved Resolved module metadata by original `resolve`.
515@return {Boolean} Whether or not to fallback.
516**/
517Y.Loader.prototype.shouldFallback = function (resolved) {
518 var modules,
519 name,
520 type;
521
522 if (this.fallbackMode) {
523 return this.fallbackMode;
524 }
525
526 for (type in TYPES) {
527 if (TYPES.hasOwnProperty(type)) {
528 modules = resolved[type + 'Mods'];
529 for (i = 0, len = modules.length; i < len; i += 1) {
530 name = modules[i].name;
531
532 if (this.pathogenSeen[name]) {
533 Y.log('Detected a request for a module that we have already seen: ' + name, 'warn', NAME);
534 Y.log('Falling back to default combo urls', 'warn', NAME);
535
536 this.fallbackMode = true;
537 return this.fallbackMode;
538 }
539 }
540 }
541 }
542};
543
544/**
545Wraps Loader's `resolve` method and uses the module metadata returned by it to
546encode pathogen urls. Note that we will incur the cost of encoding default
547combo urls until we replace the encoding logic in core.
548@method resolve
549**/
550Y.Loader.prototype.resolve = function () {
551 var resolved = resolve.apply(this, arguments),
552 combine = this.combine,
553 resolvedUrls,
554 resolvedMods,
555 comboUrls,
556 singles,
557 urlKey,
558 modKey,
559 group,
560 type,
561 url,
562 len,
563 i;
564
565 // Check to see if anything needs to be combined.
566 if (!combine) {
567 for (group in this.groups) {
568 if (this.groups.hasOwnProperty(group)) {
569 if (!combine && group.combine) {
570 combine = group.combine;
571 break;
572 }
573 }
574 }
575 }
576
577 // Add the pathogen namespace to the combo base.
578 if (Y.config.customComboBase) {
579 customComboBase = Y.config.customComboBase + NAMESPACE;
580 }
581
582 // Fallback to the default combo url if we need to.
583 if (Y.config.customComboFallback) {
584 this.pathogenSeen = this.pathogenSeen || {};
585
586 if (this.shouldFallback(resolved)) {
587 return resolved;
588 }
589 }
590
591 if (customComboBase && combine) {
592 maxURLLength = this.maxURLLength;
593
594 for (type in TYPES) {
595 /*jslint forin: false*/
596 if (!TYPES.hasOwnProperty(type)) {
597 /*jslint forin: true*/
598 continue;
599 }
600
601 singles = [];
602 urlKey = type;
603 modKey = type + 'Mods';
604
605 resolved[urlKey] = resolvedUrls = resolved[urlKey] || [];
606 resolved[modKey] = resolvedMods = resolved[modKey] || [];
607
608 for (i = 0, len = resolvedUrls.length; i < len; i += 1) {
609 url = resolvedUrls[i];
610 if (
611 typeof url === 'object' ||
612 (typeof url === 'string' && url.indexOf('/combo?') === -1)
613 ) {
614 singles.push(url);
615 }
616 }
617
618 // Generate custom combo urls.
619 comboUrls = this.customResolve(resolvedMods, type);
620
621 if (window.JSON) {
622 Y.log('Default encoding resulted in ' + resolved[type].length + ' URLs', 'info', NAME);
623 Y.log(JSON.stringify(resolved[type], null, 4), 'info', NAME);
624 Y.log('Custom encoding resulted in ' + comboUrls.length + ' URLs', 'info', NAME);
625 Y.log(JSON.stringify(comboUrls, null, 4), 'info', NAME);
626 }
627
628 resolved[type] = [].concat(comboUrls, singles);
629 }
630 }
631
632 return resolved;
633};
634
635}, '@BETA@', { requires: ['loader-base'] });