diff --git a/lib/jsdoc/util/templateHelper.js b/lib/jsdoc/util/templateHelper.js index 035e85ad..fe96e1d3 100644 --- a/lib/jsdoc/util/templateHelper.js +++ b/lib/jsdoc/util/templateHelper.js @@ -32,6 +32,33 @@ exports.globalName = name.SCOPE.NAMES.GLOBAL; exports.fileExtension = '.html'; exports.scopeToPunc = name.scopeToPunc; +var linkMap = { + // two-way lookup + longnameToUrl: {}, + urlToLongname: {}, + + // one-way lookup (IDs are only unique per file) + longnameToId: {} +}; + +// two-way lookup +var tutorialLinkMap = { + nameToUrl: {}, + urlToName: {} +}; + +var longnameToUrl = exports.longnameToUrl = linkMap.longnameToUrl; +var longnameToId = exports.longnameToId = linkMap.longnameToId; + +var registerLink = exports.registerLink = function(longname, fileUrl) { + linkMap.longnameToUrl[longname] = fileUrl; + linkMap.urlToLongname[fileUrl] = longname; +}; + +var registerId = exports.registerId = function(longname, fragment) { + linkMap.longnameToId[longname] = fragment; +}; + function getNamespace(kind) { if (dictionary.isNamespace(kind)) { return kind + ':'; @@ -39,6 +66,20 @@ function getNamespace(kind) { return ''; } +function formatNameForLink(doclet, options) { + var newName = getNamespace(doclet.kind) + (doclet.name || ''); + var scopePunc = exports.scopeToPunc[doclet.scope] || ''; + + // Only prepend the scope punctuation if it's not the same character that marks the start of a + // fragment ID. Using `#` in HTML5 fragment IDs is legal, but URLs like `foo.html##bar` are + // just confusing. + if (scopePunc !== '#') { + newName = scopePunc + newName; + } + + return newName; +} + function makeUniqueFilename(filename, str) { var key = filename.toLowerCase(); var nonUnique = true; @@ -63,32 +104,6 @@ function makeUniqueFilename(filename, str) { return filename; } -function makeUniqueId(filename, id) { - var key = id.toLowerCase(); - var nonUnique = true; - - // append enough underscores to make the identifier unique - while (nonUnique) { - if ( hasOwnProp.call(ids, filename) && ids[filename].indexOf(key) !== -1 ) { - id += '_'; - key = id.toLowerCase(); - } - else { - nonUnique = false; - } - } - - ids[filename] = ids[filename] || []; - ids[filename].push(id); - - return id; -} - -var htmlsafe = exports.htmlsafe = function(str) { - return str.replace(/&/g, '&') - .replace(/$/g, '') : ''; if ( hasUrlPrefix(stripped) ) { - url = stripped; + fileUrl = stripped; text = linkText || stripped; } // handle complex type expressions that may require multiple links @@ -235,18 +323,18 @@ function buildLink(longname, linkText, options) { return stringifyType(parsedType, options.cssClass, options.linkMap); } else { - url = hasOwnProp.call(options.linkMap, longname) ? options.linkMap[longname] : ''; + fileUrl = hasOwnProp.call(options.linkMap, longname) ? options.linkMap[longname] : ''; text = linkText || longname; } text = options.monospace ? '' + text + '' : text; - if (!url) { + if (!fileUrl) { return text; } else { - return util.format('%s', encodeURI(url + fragmentString), classString, - text); + return util.format('%s', encodeURI(fileUrl + fragmentString), + classString, text); } } @@ -328,19 +416,20 @@ function splitLinkText(text) { } var tutorialToUrl = exports.tutorialToUrl = function(tutorial) { + var fileUrl; var node = tutorials.getByName(tutorial); + // no such tutorial if (!node) { require('jsdoc/util/logger').error( new Error('No such tutorial: ' + tutorial) ); return null; } - var url; // define the URL if necessary if (!hasOwnProp.call(tutorialLinkMap.nameToUrl, node.name)) { - url = 'tutorial-' + getUniqueFilename(node.name); - tutorialLinkMap.nameToUrl[node.name] = url; - tutorialLinkMap.urlToName[url] = node.name; + fileUrl = 'tutorial-' + getUniqueFilename(node.name); + tutorialLinkMap.nameToUrl[node.name] = fileUrl; + tutorialLinkMap.urlToName[fileUrl] = node.name; } return tutorialLinkMap.nameToUrl[node.name]; @@ -480,20 +569,6 @@ var find = exports.find = function(data, spec) { return data(spec).get(); }; -/** - * Check whether a symbol is the only symbol exported by a module (as in - * `module.exports = function() {};`). - * - * @private - * @param {module:jsdoc/doclet.Doclet} doclet - The doclet for the symbol. - * @return {boolean} `true` if the symbol is the only symbol exported by a module; otherwise, - * `false`. - */ -function isModuleExports(doclet) { - return doclet.longname && doclet.longname === doclet.name && - doclet.longname.indexOf(MODULE_NAMESPACE) === 0 && doclet.kind !== 'module'; -} - /** * Retrieve all of the following types of members from a set of doclets: * @@ -771,38 +846,25 @@ exports.prune = function(data) { return data; }; -var registerLink = exports.registerLink = function(longname, url) { - linkMap.longnameToUrl[longname] = url; - linkMap.urlToLongname[url] = longname; -}; - /** - * Get a longname's filename if one has been registered; otherwise, generate a unique filename, then - * register the filename. - * @private + * Create a URL that points to the generated documentation for the doclet. + * + * If a doclet corresponds to an output file (for example, if the doclet represents a class), the + * URL will consist of a filename. + * + * If a doclet corresponds to a smaller portion of an output file (for example, if the doclet + * represents a method), the URL will consist of a filename and a fragment ID. + * + * @param {module:jsdoc/doclet.Doclet} doclet - The doclet that will be used to create the URL. + * @return {string} The URL to the generated documentation for the doclet. */ -function getFilename(longname) { - var url; - - if ( longnameToUrl[longname] && hasOwnProp.call(longnameToUrl, longname) ) { - url = longnameToUrl[longname]; - } else { - url = getUniqueFilename(longname); - registerLink(longname, url); - } - - return url; -} - -/** Turn a doclet into a URL. */ exports.createLink = function(doclet) { - var filename; - var fragment; - var match; var fakeContainer; - - var url = ''; + var filename; + var fileUrl; + var fragment = ''; var longname = doclet.longname; + var match; // handle doclets in which doclet.longname implies that the doclet gets its own HTML file, but // doclet.kind says otherwise. this happens due to mistagged JSDoc (for example, a module that @@ -822,22 +884,23 @@ exports.createLink = function(doclet) { // mistagged version of a doclet that gets its own HTML file else if ( containers.indexOf(doclet.kind) === -1 && fakeContainer ) { filename = getFilename(doclet.memberof || longname); - if (doclet.name === doclet.longname) { - fragment = ''; - } - else { - fragment = doclet.name || ''; + if (doclet.name !== doclet.longname) { + fragment = formatNameForLink(doclet); + fragment = getId(longname, fragment); } } // the doclet is within another HTML file else { filename = getFilename(doclet.memberof || exports.globalName); - fragment = getNamespace(doclet.kind) + (doclet.name || ''); + if ( (doclet.name !== doclet.longname) || (doclet.scope === name.SCOPE.NAMES.GLOBAL) ) { + fragment = formatNameForLink(doclet); + fragment = getId(longname, fragment); + } } - url = encodeURI( filename + fragmentHash(fragment) ); + fileUrl = encodeURI( filename + fragmentHash(fragment) ); - return url; + return fileUrl; }; // TODO: docs diff --git a/test/specs/jsdoc/util/templateHelper.js b/test/specs/jsdoc/util/templateHelper.js index 940bb5eb..1e384dd7 100644 --- a/test/specs/jsdoc/util/templateHelper.js +++ b/test/specs/jsdoc/util/templateHelper.js @@ -265,8 +265,48 @@ describe("jsdoc/util/templateHelper", function() { }); }); - xdescribe('getUniqueId', function() { - // TODO + describe('getUniqueId', function() { + it('should return the provided string in normal cases', function() { + var id = helper.getUniqueId('futon.html', 'backrest'); + expect(id).toBe('backrest'); + }); + + it('should return an empty string if no base ID is provided', function() { + var id = helper.getUniqueId('futon.html', ''); + expect(id).toBe(''); + }); + + it('should remove whitespace characters', function() { + var id = helper.getUniqueId('futon.html', 'a very long identifier'); + expect(id).toBe('averylongidentifier'); + }); + + it('should not return the same ID twice for a given file', function() { + var filename = 'futon.html'; + var name = 'polymorphic'; + var id1 = helper.getUniqueId(filename, name); + var id2 = helper.getUniqueId(filename, name); + + expect(id1).not.toBe(id2); + }); + + it('should allow duplicate IDs if they are in different files', function() { + var name = 'magnificence'; + var id1 = helper.getUniqueId('supersensational.html', name); + var id2 = helper.getUniqueId('razzledazzle.html', name); + + expect(id1).toBe(id2); + }); + + it('should not consider the same name with different letter case to be unique', function() { + var camel = 'myJavaScriptIdentifier'; + var pascal = 'MyJavaScriptIdentifier'; + var filename = 'mercutio.html'; + var id1 = helper.getUniqueId(filename, camel); + var id2 = helper.getUniqueId(filename, pascal); + + expect( id1.toLowerCase() ).not.toBe( id2.toLowerCase() ); + }); }); describe("longnameToUrl", function() { @@ -1356,7 +1396,8 @@ describe("jsdoc/util/templateHelper", function() { var mockDoclet = { kind: 'function', longname: 'foo', - name: 'foo' + name: 'foo', + scope: 'global' }, url = helper.createLink(mockDoclet); @@ -1468,6 +1509,45 @@ describe("jsdoc/util/templateHelper", function() { expect(badModuleDocletUrl).toBe('module-qux.html'); expect(memberDocletUrl).toBe('module-qux.html#frozzle'); }); + + it('should include the scope punctuation in the fragment ID for static members', function() { + var functionDoclet = { + kind: 'function', + longname: 'Milk.pasteurize', + name: 'pasteurize', + memberof: 'Milk', + scope: 'static' + }; + var docletUrl = helper.createLink(functionDoclet); + + expect(docletUrl).toBe('Milk.html#.pasteurize'); + }); + + it('should include the scope punctuation in the fragment ID for inner members', function() { + var functionDoclet = { + kind: 'function', + longname: 'Milk~removeSticksAndLeaves', + name: 'removeSticksAndLeaves', + memberof: 'Milk', + scope: 'inner' + }; + var docletUrl = helper.createLink(functionDoclet); + + expect(docletUrl).toBe('Milk.html#~removeSticksAndLeaves'); + }); + + it('should omit the scope punctuation from the fragment ID for instance members', function() { + var propertyDoclet = { + kind: 'member', + longname: 'Milk#calcium', + name: 'calcium', + memberof: 'Milk', + scope: 'instance' + }; + var docletUrl = helper.createLink(propertyDoclet); + + expect(docletUrl).toBe('Milk.html#calcium'); + }); }); describe("resolveAuthorLinks", function() {