diff --git a/config/jsdoc/api/plugins/api.js b/config/jsdoc/api/plugins/api.js index 0ebda4f356..b5ff2190ab 100644 --- a/config/jsdoc/api/plugins/api.js +++ b/config/jsdoc/api/plugins/api.js @@ -106,6 +106,20 @@ function includeTypes(doclet) { } } +const defaultExports = {}; +const path = require('path'); +const moduleRoot = path.join(process.cwd(), 'src'); + +// Tag default exported Identifiers because their name should be the same as the module name. +exports.astNodeVisitor = { + visitNode: function(node, e, parser, currentSourceName) { + if (node.type === 'Identifier' && node.parent.type === 'ExportDefaultDeclaration') { + const modulePath = path.relative(moduleRoot, currentSourceName).replace(/\.js$/, ''); + defaultExports['module:' + modulePath + '~' + node.name] = true; + } + } +}; + exports.handlers = { newDoclet: function(e) { @@ -169,6 +183,15 @@ exports.handlers = { delete doclet.undocumented; } } + }, + + processingComplete(e) { + const byLongname = e.doclets.index.longname; + for (const name in defaultExports) { + byLongname[name].forEach(function(doclet) { + doclet.isDefaultExport = true; + }); + } } }; diff --git a/config/jsdoc/api/template/publish.js b/config/jsdoc/api/template/publish.js index ecda2d2fb1..c1d4b56156 100644 --- a/config/jsdoc/api/template/publish.js +++ b/config/jsdoc/api/template/publish.js @@ -14,7 +14,6 @@ const path = require('jsdoc/lib/jsdoc/path'); const taffy = require('taffydb').taffy; const handle = require('jsdoc/lib/jsdoc/util/error').handle; const helper = require('jsdoc/lib/jsdoc/util/templateHelper'); -const _ = require('underscore'); const htmlsafe = helper.htmlsafe; const linkto = helper.linkto; const resolveAuthorLinks = helper.resolveAuthorLinks; @@ -188,10 +187,12 @@ function attachModuleSymbols(doclets, modules) { }); } -function getPrettyName(longname) { - return longname - .split('~')[0] - .replace('module:', ''); +function getPrettyName(doclet) { + const fullname = doclet.longname.replace('module:', ''); + if (doclet.isDefaultExport) { + return fullname.split('~')[0]; + } + return fullname; } /** @@ -209,27 +210,13 @@ function getPrettyName(longname) { */ function buildNav(members) { const nav = []; - // merge namespaces and classes, then sort - const merged = members.modules.concat(members.classes); - merged.sort(function(a, b) { - const prettyNameA = getPrettyName(a.longname).toLowerCase(); - const prettyNameB = getPrettyName(b.longname).toLowerCase(); - if (prettyNameA > prettyNameB) { - return 1; - } - if (prettyNameA < prettyNameB) { - return -1; - } - return 0; - }); - - _.each(merged, function(v) { + members.classes.forEach(function(v) { // exclude interfaces from sidebar - if (v.interface !== true && v.kind === 'class') { + if (v.interface !== true) { nav.push({ type: 'class', longname: v.longname, - prettyname: getPrettyName(v.longname), + prettyname: getPrettyName(v), name: v.name, module: find({ kind: 'module', @@ -253,44 +240,57 @@ function buildNav(members) { memberof: v.longname }) }); - } else if (v.kind == 'module') { - const classes = find({ - kind: 'class', - memberof: v.longname - }); - const members = find({ - kind: 'member', - memberof: v.longname - }); - const methods = find({ - kind: 'function', - memberof: v.longname - }); - const typedefs = find({ - kind: 'typedef', - memberof: v.longname - }); - const events = find({ - kind: 'event', - memberof: v.longname - }); - // Only add modules that contain more than just classes with their - // associated Options typedef - if (typedefs.length > classes.length || members.length + methods.length > 0) { - nav.push({ - type: 'module', - longname: v.longname, - prettyname: getPrettyName(v.longname), - name: v.name, - members: members, - methods: methods, - typedefs: typedefs, - fires: v.fires, - events: events - }); - } } }); + members.modules.forEach(function(v) { + const classes = find({ + kind: 'class', + memberof: v.longname + }); + const members = find({ + kind: 'member', + memberof: v.longname + }); + const methods = find({ + kind: 'function', + memberof: v.longname + }); + const typedefs = find({ + kind: 'typedef', + memberof: v.longname + }); + const events = find({ + kind: 'event', + memberof: v.longname + }); + // Only add modules that contain more than just classes with their + // associated Options typedef + if (typedefs.length > classes.length || members.length + methods.length > 0) { + nav.push({ + type: 'module', + longname: v.longname, + prettyname: getPrettyName(v), + name: v.name, + members: members, + methods: methods, + typedefs: typedefs, + fires: v.fires, + events: events + }); + } + }); + + nav.sort(function(a, b) { + const prettyNameA = a.prettyname.toLowerCase(); + const prettyNameB = b.prettyname.toLowerCase(); + if (prettyNameA > prettyNameB) { + return 1; + } + if (prettyNameA < prettyNameB) { + return -1; + } + return 0; + }); return nav; } diff --git a/config/jsdoc/api/template/static/scripts/main.js b/config/jsdoc/api/template/static/scripts/main.js index 97dfd0facf..8a053e5c7a 100644 --- a/config/jsdoc/api/template/static/scripts/main.js +++ b/config/jsdoc/api/template/static/scripts/main.js @@ -1,99 +1,258 @@ $(function () { - // Search Items - $('#include_modules').change(function (e) { - console.log('change'); - if ($(this).is(':checked')) { + 'use strict'; + // Allow user configuration? + const allowRegex = true; + const minInputForSearch = 1; + const minInputForFullText = 2; + const expandAllOnInputWithoutSearch = true; + + function constructRegex(searchTerm, makeRe, allowRegex) { + try { + if (allowRegex) { + return makeRe(searchTerm); + } + } catch (e) { + } + // In case of invalid regexp fall back to non-regexp, but still allow . to match / + return makeRe(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\./g, '[./]')); + } + + function getWeightFunction(searchTerm, allowRegex) { + function makeRe(searchTerm) { + return { + begin: new RegExp('\\b' + searchTerm), // Begin matches word boundary + baseName: new RegExp('\\b' + searchTerm + '[^/]*$'), // Begin matches word boundary of class / module name + fullName: new RegExp('\\b' + searchTerm + '(?:[~.]|$)'), // Complete word(s) of class / module matches + completeName: new RegExp('^' + searchTerm + '$') // Match from start to finish + } + } + const re = constructRegex(searchTerm, makeRe, allowRegex); + return function (matchedItem, beginOnly) { + // We could get smarter on the weight here + const name = matchedItem.dataset.name; + if (beginOnly) { + return re.baseName.test(name) ? 100 : 1; + } + // If everything else is equal, prefer shorter names, and prefer classes over modules + let weight = 10000 + matchedItem.dataset.longname.length - name.length * 100; + if (name.match(re.begin)) { + weight += 10000; + if (re.baseName.test(name)) { + weight += 10000; + if (re.fullName.test(name)) { + weight += 10000; + if (re.completeName.test(name)) { + weight += 10000; + } + } + } + } + return weight; + } + } + + const search = (function () { + const $navList = $('.navigation-list'); + const navListNode = $navList.get(0); + let $classItems; + let $members; + let stateClass = (function () { + $navList.removeClass('search-started searching'); + $navList.addClass('search-empty'); + return 'search-empty'; + })(); + let manualToggles = {}; + + // Show an item related a current documentation automatically + const longname = $('.page-title').data('filename') + .replace(/\.[a-z]+$/, '') + .replace('module-', 'module:') + .replace(/_/g, '/') + .replace(/-/g, '~'); + const currentItem = navListNode.querySelector('.item[data-longname="' + longname + '"]'); + if (currentItem) { + $navList.prepend(currentItem); + } + return { + $navList: $navList, + $currentItem: currentItem ? $(currentItem) : undefined, + lastSearchTerm: undefined, + lastState: {}, + getClassList: function () { + return $classItems || ($classItems = $navList.find('li.item')); + }, + getMembers: function () { + return $members || ($members = $navList.find('.item li')); + }, + changeStateClass: function (newClass) { + if (newClass !== stateClass) { + navListNode.classList.remove(stateClass); + navListNode.classList.add(newClass); + stateClass = newClass; + } + }, + manualToggle: function ($node, show) { + $node.addClass('toggle-manual'); + $node.toggleClass('toggle-manual-hide', !show); + $node.toggleClass('toggle-manual-show', show); + manualToggles[$node.data('longname')] = $node; + }, + clearManualToggles: function() { + for (let clsName in manualToggles) { + manualToggles[clsName].removeClass('toggle-manual toggle-manual-show toggle-manual-hide'); + } + manualToggles = {}; + }, + }; + })(); + + const dummy = {subItems: {}}; + function clearOldMatches(lastState, searchState) { + for (let itemName in lastState) { + const lastItem = lastState[itemName]; + const item = searchState[itemName]; + if (!item) { + lastItem.item.classList.remove('match'); + } + if (lastItem.subItems) { + clearOldMatches(lastItem.subItems, (item || dummy).subItems); + } + } + } + + function doSearch(searchTerm) { + searchTerm = searchTerm.toLowerCase(); + const lastSearchTerm = search.lastSearchTerm; + if (searchTerm === lastSearchTerm) { + return; + } + + // Avoid layout reflow by scrolling to top first. + search.$navList.scrollTop(0); + search.lastSearchTerm = searchTerm; + search.clearManualToggles(); + + if (searchTerm.length < minInputForSearch) { + const state = searchTerm.length && expandAllOnInputWithoutSearch ? 'search-started' : 'search-empty'; + search.changeStateClass(state); + if (lastSearchTerm !== undefined && lastSearchTerm.length >= minInputForSearch) { + // Restore the original, sorted order + search.$navList.append(search.getClassList()); + } + if (state === 'search-empty' && search.$currentItem) { + search.manualToggle(search.$currentItem, true); + } } else { - - } - }); - - var getSearchWeight = function (searchTerm, $matchedItem) { - let weight = 0; - // We could get smarter on the weight here - if ($matchedItem.data('shortname') - && $matchedItem.data('shortname').toLowerCase() === searchTerm.toLowerCase()) { - weight++; - } - return weight; - }; - - // sort function callback - var weightSorter = function (a, b) { - var aW = $(a).data('weight') || 0; - var bW = $(b).data('weight') || 0; - return bW - aW; - }; - - // Search Items - $('#search').on('keyup', function (e) { - var value = $(this).val(); - var $el = $('.navigation'); - - if (value && value.length > 1) { - var regexp = new RegExp(value, 'i'); - $el.find('li, .member-list').hide(); - - $el.find('li').each(function (i, v) { - const $item = $(v); - const name = $item.data('name'); - - if (name && regexp.test(name)) { - const $classEntry = $item.closest('.item'); - const $members = $item.closest('.member-list'); - - // Do the weight thing - $classEntry.removeData('weight'); - $classEntry.show(); - const weight = getSearchWeight(value, $classEntry); - $classEntry.data('weight', weight); - - $members.show(); - $classEntry.show(); - $item.show(); + search.changeStateClass('searching'); + searchTerm = searchTerm.toLowerCase(); + const beginOnly = searchTerm.length < minInputForFullText; + const getSearchWeight = getWeightFunction(searchTerm, allowRegex); + const re = constructRegex(searchTerm, function (searchTerm) { + return new RegExp((beginOnly ? '\\b' : '') + searchTerm); + }, allowRegex); + const navList = search.$navList.get(0); + const classes = []; + const searchState = {}; + search.getClassList().each(function (i, classEntry) { + const className = classEntry.dataset.longname; + if (!(className in searchState) && re.test(classEntry.dataset.name)) { + const cls = searchState[className] = { + item: classEntry, + // Do the weight thing + weight: getSearchWeight(classEntry, beginOnly) * 100000, + subItems: {} + }; + classes.push(cls); + classEntry.classList.add('match'); } }); + search.getMembers().each(function (i, li) { + const name = li.dataset.name; + if (re.test(name)) { + const itemMember = li.parentElement.parentElement; + const classEntry = itemMember.parentElement; + const className = classEntry.dataset.longname; + let cls = searchState[className]; + if (!cls) { + cls = searchState[className] = { + item: classEntry, + weight: 0, + subItems: {} + }; + classes.push(cls); + classEntry.classList.add('match'); + } + cls.weight += getSearchWeight(li, true); + const memberType = itemMember.dataset.type; + let members = cls.subItems[memberType]; + if (!members) { + members = cls.subItems[memberType] = { + item: itemMember, + subItems: {} + }; + itemMember.classList.add('match'); + } + members.subItems[name] = { item: li }; + li.classList.add('match'); + } + }); + clearOldMatches(search.lastState, searchState); + search.lastState = searchState; - $(".navigation ul.list li.item:visible") - .sort(weightSorter) // sort elements - .appendTo(".navigation ul.list"); // append again to the list - - } else { - $el.find('.item, .member-list').show(); + classes.sort(function (a, b) { + return a.weight - b.weight; + }); + for (let i = classes.length - 1; i >= 0; --i) { + navList.appendChild(classes[i].item); + } } + } - $el.find('.list').scrollTop(0); - }); + const searchInput = $('#search').get(0); + // Skip searches when typing fast. + let key; + function queueSearch() { + if (!key) { + key = setTimeout(function () { + key = undefined; + + const searchTerm = searchInput.value; + doSearch(searchTerm); + }, 0); + } + } + + // Search Items + searchInput.addEventListener('input', queueSearch); + doSearch(searchInput.value); // Toggle when click an item element - $('.navigation').on('click', '.toggle', function (e) { - $(this).parent().parent().find('.member-list').toggle(); + search.$navList.on('click', '.toggle', function (e) { + if (event.target.tagName.toLowerCase() === 'a') { + return; + } + const clsItem = $(this).closest('.item'); + let show; + if (clsItem.hasClass('toggle-manual-show')) { + show = false; + } else if (clsItem.hasClass('toggle-manual-hide')) { + show = true; + } else { + clsItem.find('.member-list li').each(function (i, v) { + show = $(v).is(':hidden'); + return !show; + }); + } + search.manualToggle(clsItem, !!show); }); - // Show an item related a current documentation automatically - var filename = $('.page-title').data('filename') - .replace(/\.[a-z]+$/, '') - .replace('module-', 'module:') - .replace(/_/g, '/') - .replace(/-/g, '~'); - var $currentItem = $('.navigation .item[data-name*="' + filename + '"]:eq(0)'); - - if ($currentItem.length) { - $currentItem - .remove() - .prependTo('.navigation .list') - .show() - .find('.member-list') - .show(); - } - // Auto resizing on navigation var _onResize = function () { var height = $(window).height(); var $el = $('.navigation'); - $el.height(height).find('.list').height(height - 133); + $el.height(height).find('.navigation-list').height(height - 133); }; $(window).on('resize', _onResize); @@ -137,22 +296,4 @@ $(function () { '' + textParts[1] + ''; }); - - // Highlighting current anchor - - var anchors = $('.anchor'); - var _onHashChange = function () { - var activeHash = window.document.location.hash - .replace(/\./g, '\\.') // Escape dot in element id - .replace(/\~/g, '\\~'); // Escape tilde in element id - - anchors.removeClass('highlighted'); - - if (activeHash.length > 0) { - anchors.filter(activeHash).addClass('highlighted'); - } - }; - - $(window).on('hashchange', _onHashChange); - _onHashChange(); }); diff --git a/config/jsdoc/api/template/static/styles/jaguar.css b/config/jsdoc/api/template/static/styles/jaguar.css index 1a677bfa93..43a455cadf 100644 --- a/config/jsdoc/api/template/static/styles/jaguar.css +++ b/config/jsdoc/api/template/static/styles/jaguar.css @@ -51,7 +51,8 @@ body { width: 0px; height: 0px; } -.nameContainer .anchor.highlighted + h4 { +/* Highlighting current anchor */ +.nameContainer .anchor:target + h4 { background-color: #faebcc; } a { @@ -123,7 +124,7 @@ li { color: #fff; border-color: #555; } -.navigation .list { +.navigation .navigation-list { padding: 10px 15px 0 15px; position: relative; overflow: auto; @@ -135,6 +136,24 @@ li { border-bottom: 1px solid #333; } +.navigation .glyphicon { + margin-right: 3px; + flex-shrink: 0; +} +.navigation .item .glyphicon:before { + display: inline-block; +} +.navigation .item.toggle-manual .glyphicon:before { + transition: transform .1s; +} +.navigation .item-class.toggle-manual-show .glyphicon:before { + /* With 90deg the icon slightly slides left at transition end */ + transform: rotate(89.9deg); +} +.navigation .item-module.toggle-manual-show .glyphicon:before { + transform: rotate(45deg); +} + .navigation li.perfect-match { border: 5px solid orange; } @@ -147,8 +166,8 @@ li { } .navigation li.item .title { cursor: pointer; - position: relative; - display: block; + display: -ms-flexbox; + display: flex; font-size: 0.85em; } .navigation li.item .title a { @@ -184,9 +203,41 @@ li { margin-top: 2px; } .navigation li.item .member-list { - display: none; padding-left: 8px; } + +/* search state */ +/* show all classes when search is empty */ +.navigation-list.search-empty .item { + display: block; +} +/* hide all members by default when search is empty */ +.navigation-list.search-empty .item .member-list { + display: none; +} +/* expand all members when input in search field available but too short to search */ +.navigation-list.search-started li, +.navigation-list.search-started .member-list { + display: block; +} +/* when searching hide everything that is not a match */ +.navigation-list.searching li, +.navigation-list.searching .member-list { + display: none; +} +.navigation-list.searching .match { + display: block; +} +/* allow user to hide / show members */ +.navigation-list .item.toggle-manual-show li, +.navigation-list .item.toggle-manual-show .member-list { + display: block!important; +} +.navigation-list:not(.searching) .item.toggle-manual-hide li, +.navigation-list:not(.searching) .item.toggle-manual-hide .member-list { + display: none!important; +} + .main { padding: 20px 20px; margin-left: 250px; diff --git a/config/jsdoc/api/template/tmpl/navigation.tmpl b/config/jsdoc/api/template/tmpl/navigation.tmpl index 074e99672f..ac3dc9f227 100644 --- a/config/jsdoc/api/template/tmpl/navigation.tmpl +++ b/config/jsdoc/api/template/tmpl/navigation.tmpl @@ -15,17 +15,17 @@ function getItemCssClass(type) { } const printList = v => { ?> -