diff --git a/.gitignore b/.gitignore index 2116c155..2e8dcfd9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store .env .nyc_output coverage @@ -5,3 +6,4 @@ node_modules/ npm-debug.log public/ bower_components/ +.vscode/ diff --git a/.snyk b/.snyk new file mode 100644 index 00000000..c5e9abfb --- /dev/null +++ b/.snyk @@ -0,0 +1,9 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.12.0 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + 'npm:mem:20180117': + - showdown > yargs > os-locale > mem: + reason: No patch avalible yet and DoS of registry README is not a high risk. + expires: '2018-11-02T17:09:51.357Z' +patch: {} diff --git a/README.md b/README.md index a035d04f..27c64a72 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Get information about Origami components, services, and repositories. ## Requirements -Running Origami Registry UI requires [Node.js] 8.x and [npm]. +Running Origami Registry UI requires [Node.js] 10.x and [npm]. ## Running Locally diff --git a/bower.json b/bower.json index 3d14ad0a..172d6a04 100644 --- a/bower.json +++ b/bower.json @@ -17,11 +17,12 @@ "o-footer-services": "^2.0.1", "o-forms": "^5.3.0", "o-message": "^2.2.2", - "o-syntax-highlight": "^1.0.0", + "o-syntax-highlight": "^1.2.0", "o-tabs": "^4.1.0", "o-typography": "^5.6.0", "o-normalise": "^1.6.2", "o-overlay": "^2.4.1", + "o-table": "^6.8.1", "o-visual-effects": "^2.0.3" } } diff --git a/circle.yml b/circle.yml index e9f702b9..943bbfd5 100644 --- a/circle.yml +++ b/circle.yml @@ -3,16 +3,16 @@ jobs: test: working_directory: ~/origami-registry-ui docker: - - image: node:8 + - image: node:10 steps: - checkout - restore_cache: - key: dependency-cache-{{ checksum "package.json" }} + key: node-10-dependency-cache-{{ checksum "package.json" }} - run: name: Install Node.js dependencies command: npm install - save_cache: - key: dependency-cache-{{ checksum "package.json" }} + key: node-10-dependency-cache-{{ checksum "package.json" }} paths: - node_modules - run: diff --git a/index.js b/index.js index 0679a311..6b443f75 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,8 @@ const options = { log: console, name: 'Origami Registry', repoDataApiKey: process.env.REPO_DATA_API_KEY, + codedocsApiKey: process.env.CODEDOCS_API_KEY, + codedocsEndpoint: process.env.CODEDOCS_ENDPOINT, repoDataApiSecret: process.env.REPO_DATA_API_SECRET, workers: process.env.WEB_CONCURRENCY || 1 }; diff --git a/lib/code-docs/example.js b/lib/code-docs/example.js new file mode 100644 index 00000000..443a8c36 --- /dev/null +++ b/lib/code-docs/example.js @@ -0,0 +1,11 @@ +'use strict'; + +class Example { + constructor(code, type = '', caption = '') { + this.caption = caption ? `${caption}\n` : ''; // newline to render as
with markdown + this.type = type || ''; + this.code = code; + } +} + +module.exports = Example; diff --git a/lib/code-docs/jsdoc/index.js b/lib/code-docs/jsdoc/index.js new file mode 100644 index 00000000..091d4528 --- /dev/null +++ b/lib/code-docs/jsdoc/index.js @@ -0,0 +1,116 @@ +'use strict'; + +const ClassNode = require('./nodes/class'); +const EventNode = require('./nodes/event'); +const FunctionNode = require('./nodes/function'); +const MixinNode = require('./nodes/mixin'); +const NamespaceNode = require('./nodes/namespace'); +const PropertyNode = require('./nodes/property'); +const ModuleNode = require('./nodes/module'); + +/** + * Methods to prepare JsDoc json doclets for display within the registry. + */ +class JsDoc { + /** + * @param {Object[]} doclets - Raw json doclets generated by JSDoc. + */ + constructor(doclets) { + this.doclets = doclets; + } + + /** + * @returns {string[]} Kinds of JSDoc doclets which are supported. + */ + static supportedDoclets() { + return ['class', 'function', 'constant', 'member', 'event', 'namespace', 'mixin', 'module']; + } + + /** + * Format a doclet by removing superfluous properties and adding any custom properties. + * @param {Object} doclet - A raw json doclet generated by JSDoc. + * @returns {Object} Formatted node. + */ + static formatDoclet(doclet) { + switch (doclet.kind) { + case 'class': + return new ClassNode(doclet); + break; + case 'function': + return new FunctionNode(doclet); + break; + case 'constant': + case 'member': + return new PropertyNode(doclet); + break; + case 'event': + return new EventNode(doclet); + break; + case 'namespace': + return new NamespaceNode(doclet); + break; + case 'mixin': + return new MixinNode(doclet); + break; + case 'module': + return new ModuleNode(doclet); + break; + default: + throw new Error(`JsDoc doclet kind "${doclet.kind}" is not supported by the registry.`); + break; + }; + } + + /** + * @returns {Object[]} Formatted nodes. + */ + getNodes() { + if (!this._formattedDoclets) { + const supportedDoclets = this.doclets.filter((doclet) => { + const isNotIndicatedPrivate = doclet.name && doclet.name.indexOf('_') !== 0; + const isSupported = doclet.kind && JsDoc.supportedDoclets().includes(doclet.kind); + const isPublic = doclet.access !== 'private'; + const isDocumented = doclet.undocumented !== true; + return isSupported && isPublic && isNotIndicatedPrivate && isDocumented; + }); + + this._formattedDoclets = supportedDoclets.map((doclet) => { + return JsDoc.formatDoclet(doclet); + }); + } + return this._formattedDoclets; + } + + /** + * @returns {Object[]} Formatted nodes. + */ + getNodesByTypeWithMembers() { + if (!this._formattedDocletsWithMembers) { + this._formattedDocletsWithMembers = JsDoc._addChildNodes(this.getNodes(), {}); + } + return this._formattedDocletsWithMembers; + } + + /** + * Add member nodes to parent nodes. E.g. add function nodes to their corresponding class node. + * @param {Object[]} nodes [{}] - Formatted JSDoc doclets. + * @param {Object|Object[]} parentNodes - Root nodes to add member nodes to (optional). + * @returns {Object[]} Nodes with member nodes grouped by kind e.g. {name: 'Example', type: class, examples: [{}], functions: [{}], properties: [{}]} + * @access private + */ + static _addChildNodes(nodes, parentNodes = {}) { + parentNodes = Array.isArray(parentNodes) ? parentNodes : [parentNodes]; + const nodesWithMembers = parentNodes.map((parentNode) => { + const memberNodes = nodes.filter((node) => node.memberof === parentNode.longname); + return memberNodes.reduce((parentNode, node) => { + JsDoc._addChildNodes(nodes, node); + parentNode[node.group] = parentNode[node.group] || []; + parentNode[node.group].push(node); + return parentNode; + }, parentNode); + }); + return (nodesWithMembers.length === 1 ? nodesWithMembers[0] : nodesWithMembers); + } +} + +module.exports = JsDoc; diff --git a/lib/code-docs/jsdoc/nav.js b/lib/code-docs/jsdoc/nav.js new file mode 100644 index 00000000..23e4be48 --- /dev/null +++ b/lib/code-docs/jsdoc/nav.js @@ -0,0 +1,69 @@ +'use strict'; + +const NavNode = require('../nav-node'); + +/** + * Methods to build a navigation for JSDoc. + */ +class JsDocNav { + + /** + * @param {JsDoc} JsDoc - Raw json doclets generated by JSDoc. + * @returns {NavNode[]} A navigation composing an array of sub-navigations e.g. [{title, items: [{title, link}]}] + */ + static createNavigation(JsDoc) { + const nav = []; + const nodes = JsDoc.getNodes(); + // Add important doclets to the root of the nav with subnavs. + // E.g. add classes with funciton and proeprties as a subnav. + const jsDocByTypeWithMembers = JsDoc.getNodesByTypeWithMembers(); + const docletGroups = [ + { 'name': 'classes', 'subGroups': ['functions', 'properties'] }, + { 'name': 'modules', 'subGroups': ['classes', 'functions', 'properties'] }]; + docletGroups.forEach(docletGroup => { + if (jsDocByTypeWithMembers[docletGroup.name]) { + jsDocByTypeWithMembers[docletGroup.name].forEach((node) => { + nav.push(createSubNav( + `${node.name}`, + [].concat([node], ...docletGroup.subGroups.map(name => node[name])) + )); + }); + } + }); + // Add events to nav, ignoring their hierarchy. + // I.e. Surface events which are not in the global scope. + const eventNodes = nodes.filter(node => node.kind === 'event'); + if (eventNodes.length > 0) { + nav.push(createSubNav('Events', eventNodes)); + } + // Add global nodes which are not already added to the nav. + const globalNodes = nodes.filter(node => node.memberof === undefined && ['class', 'module', 'events'].includes(node.kind) === false); + if (globalNodes.length > 0) { + nav.push(createSubNav('Global', globalNodes)); + } + return nav; + } +} + +/** + * @param {string} title - The title of the subnav. + * @param {Object[]} nodes - Formatted JS doclets to link to from the sub-navigation. + * @returns {NavNode} A sub-navigation e.g. {title, items: [{title, link}]} + * @access private + */ +function createSubNav(title, nodes) { + nodes = nodes || []; + const items = nodes.filter(node => node).map((node) => { + let title = `${node.label}: ${node.name}`; + if (node.kind === 'class' && node.memberof === undefined) { + title = 'Constructor & Overview'; + } + if (node.kind === 'module' && node.memberof === undefined) { + title = 'Overview'; + } + return new NavNode(title, `#${node.longname}`); + }); + return new NavNode(title, items); +} + +module.exports = JsDocNav; diff --git a/lib/code-docs/jsdoc/nodes/base.js b/lib/code-docs/jsdoc/nodes/base.js new file mode 100644 index 00000000..ca4e1c6a --- /dev/null +++ b/lib/code-docs/jsdoc/nodes/base.js @@ -0,0 +1,100 @@ +'use strict'; + +const Example = require('../../example'); +const { URL } = require('url'); + +class BaseNode { + constructor(doclet) { + this.name = doclet.name; + this.longname = doclet.longname; + this.kind = doclet.kind; + this.memberof = doclet.memberof; + this.description = this.replaceLinks(doclet.description) || ''; + this.access = doclet.access || ''; + this.virtual = Boolean(doclet.virtual); + this.file = { + path: doclet.meta && doclet.meta.path ? doclet.meta.path : '', + name: doclet.meta && doclet.meta.filename ? doclet.meta.filename : '' + }; + + if (doclet.meta && isNaN(doclet.meta.lineno) === false) { + this.file.lineno = doclet.meta.lineno; + } + if (doclet.meta && isNaN(doclet.meta.columnno) === false) { + this.file.columnno = doclet.meta.columnno; + } + if (doclet.deprecated) { + this.deprecated = this.replaceLinks(doclet.deprecated); + } + } + + replaceLinks(jsDocComment) { + if (typeof jsDocComment !== 'string') { + return jsDocComment; + } + return jsDocComment.replace(/(?:\[(.*)\])?{@link ([^\s|}]*)\s?(?:[|])?([^}]*)}/g, (match, p1, p2, p3) => { + //{@link namepathOrURL} + let href = p2; + let text = null; + //[link text]{@link namepathOrURL} + text = text || p1; + //{@link namepathOrURL|text} + //{@link namepathOrURL text (after the first space)} + text = text || p3; + // If no text show url/namepath. + text = text || href; + // If unable to construct url assume a JSDoc namepath and turn into a hash. + try { + new URL(href); + } catch (error) { + href = `#${href}`; + } + return `[${text}](${href})`; + }); + } + + addExamples(doclet, target = this) { + target.examples = target.examples || []; + if (Array.isArray(doclet.examples)) { + doclet.examples.forEach((example) => { + let code = example; + let caption; + + /** @see https://github.com/jsdoc3/jsdoc/blob/832dfd704a01fc931ef54ef0a02896d89a5ee9ad/templates/default/publish.js#L470 */ + if (example.match(/^\s*
Code example!
+ `; + const type = 'html'; + const caption = 'My HTML example.'; + + it('creates an example with just code', () => { + const example = new Example(code); + assert.equal(example.code, code, 'Did not add code.'); + assert.equal(example.type, '', 'Did not add empty type.'); + assert.equal(example.caption, '', 'Did not add empty caption.'); + }); + it('creates an example with a type', () => { + const example = new Example(code, type); + assert.equal(example.type, type, 'Did not add type.'); + }); + it('creates an example with a caption, enforcing a newline to render as a paragraph via markdown', () => { + const example = new Example(code, null, caption); + assert.include(example.caption, caption, 'Did not add the caption.'); + assert.include(example.caption, '\n', 'Did not enforce newline on the caption.'); + }); + it('creates an example with code, a type, and a caption', () => { + const example = new Example(code, type, caption); + assert.equal(example.code, code, 'Did not add code.'); + assert.equal(example.type, type, 'Did not add type.'); + assert.include(example.caption, caption, 'Did not add the caption.'); + }); +}); diff --git a/test/unit/lib/code-docs/jsdoc/index.test.js b/test/unit/lib/code-docs/jsdoc/index.test.js new file mode 100644 index 00000000..a544c55c --- /dev/null +++ b/test/unit/lib/code-docs/jsdoc/index.test.js @@ -0,0 +1,145 @@ +'use strict'; + +const assert = require('proclaim'); +const JsDoc = require('../../../../../lib/code-docs/jsdoc'); +const ClassNode = require('../../../../../lib/code-docs/jsdoc/nodes/class'); +const FunctionNode = require('../../../../../lib/code-docs/jsdoc/nodes/function'); +const PropertyNode = require('../../../../../lib/code-docs/jsdoc/nodes/property'); +const EventNode = require('../../../../../lib/code-docs/jsdoc/nodes/event'); +const NamespaceNode = require('../../../../../lib/code-docs/jsdoc/nodes/namespace'); +const MixinNode = require('../../../../../lib/code-docs/jsdoc/nodes/mixin'); +const ModuleNode = require('../../../../../lib/code-docs/jsdoc/nodes/module'); +const ClassDoclet = require('../../../mock/code-docs/jsdoc/class'); +const FunctionDoclet = require('../../../mock/code-docs/jsdoc/function'); +const EventDoclet = require('../../../mock/code-docs/jsdoc/event'); +const PropertyDoclet = require('../../../mock/code-docs/jsdoc/property'); +const NamespaceDoclet = require('../../../mock/code-docs/jsdoc/namespace'); +const MixinDoclet = require('../../../mock/code-docs/jsdoc/mixin'); +const ModuleDoclet = require('../../../mock/code-docs/jsdoc/module'); + +describe('lib/code-docs/jsdoc/index', () => { + + describe('supportedDoclets', () => { + it('Returns an array of supported doclet kinds', () => { + assert.isTrue(Array.isArray(JsDoc.supportedDoclets()), 'Did not return an array.'); + assert.isTrue(JsDoc.supportedDoclets().includes('class'), 'Expected at least class doclets to be supported.'); + }); + }); + + describe('getNodes', () => { + let testDoclet = {}; + beforeEach(() => { + testDoclet = { + 'kind': 'function', + 'name': 'helloWorld', + 'longname': 'helloWorld', + }; + }); + it('Removes undocumented doclets', () => { + const undocumentedDoclet = testDoclet; + undocumentedDoclet.undocumented = true; + assert.deepEqual(new JsDoc([undocumentedDoclet]).getNodes(), [], 'Did not remove undocumented doclet.'); + }); + it('Removes unsupported doclet kinds', () => { + const unsuportedKindDoclet = testDoclet; + unsuportedKindDoclet.kind = 'notarealkind'; + assert.deepEqual(new JsDoc([unsuportedKindDoclet]).getNodes(), [], 'Did not remove a doclet of an unsupported kind.'); + }); + it('Removes doclets marked private', () => { + const privateDoclet = testDoclet; + privateDoclet.access = 'private'; + assert.deepEqual(new JsDoc([privateDoclet]).getNodes(), [], 'Did not remove a private doclet.'); + }); + it('Removes pseudo private doclets (where the name starts with an underscore)', () => { + const pseudoPrivateDoclet = testDoclet; + pseudoPrivateDoclet.name = `_${pseudoPrivateDoclet.name}`; + pseudoPrivateDoclet.longname = `_${pseudoPrivateDoclet.longname}`; + assert.deepEqual(new JsDoc([pseudoPrivateDoclet]).getNodes(), [], 'Did not remove a pseudo private doclet.'); + }); + it('Does not remove a documented, supported, public doclet', () => { + assert.ok(new JsDoc([testDoclet]).getNodes()[0], 'Removed supported doclet.'); + }); + it('Formats doclets', () => { + const testJsDoc = new JsDoc([ + ClassDoclet.classDeclarationDoclet, + FunctionDoclet.globalFunctionDoclet, + ]); + const nodes = testJsDoc.getNodes(); + assert.isTrue(Array.isArray(nodes), 'Did not return an array containing formatted nodes.'); + assert.ok(nodes.find(node => node instanceof ClassNode),'Did not format the class node.'); + assert.ok(nodes.find(node => node instanceof FunctionNode),'Did not format the function node.'); + }); + }); + + describe('getNodesByTypeWithMembers', () => { + const classDoclet = { + 'kind': 'class', + 'name': 'World', + 'longname': 'World', + }; + const memberFunctionDoclet = { + 'kind': 'function', + 'name': 'hello', + 'longname': 'World#hello', + 'memberof': 'World', + }; + const testJsDoc = new JsDoc([ + classDoclet, + memberFunctionDoclet, + ]); + const nodes = testJsDoc.getNodesByTypeWithMembers(); + assert.isTrue(nodes.classes[0] instanceof ClassNode, 'Did not return an object with a classes property containing the formatted class node.'); + assert.ok(nodes.classes[0].functions.find(node => node instanceof FunctionNode), 'Did not format the function node as a member of the class node as expected.'); + }); + + describe('formatDoclet', () => { + it('Formats a class doclet', () => { + const doclet = ClassDoclet.classDeclarationDoclet; + const node = JsDoc.formatDoclet(doclet); + assert.isTrue(node instanceof ClassNode, 'Did not create a class node from a class doclet.'); + }); + it('Formats a function doclet', () => { + const doclet = FunctionDoclet.globalFunctionDoclet; + const node = JsDoc.formatDoclet(doclet); + assert.isTrue(node instanceof FunctionNode, 'Did not create a function node from a function doclet.'); + }); + it('Formats a member doclet', () => { + const doclet = PropertyDoclet.memberDoclet; + const node = JsDoc.formatDoclet(doclet); + assert.isTrue(node instanceof PropertyNode, 'Did not create a property node from a member doclet.'); + }); + it('Formats a constant doclet', () => { + const doclet = PropertyDoclet.constantDoclet; + const node = JsDoc.formatDoclet(doclet); + assert.isTrue(node instanceof PropertyNode, 'Did not create a property node from a constant doclet.'); + }); + it('Formats an event doclet', () => { + const doclet = EventDoclet.eventDoclet; + const node = JsDoc.formatDoclet(doclet); + assert.isTrue(node instanceof EventNode, 'Did not create a event node from a event doclet.'); + }); + it('Formats a namespace doclet', () => { + const doclet = NamespaceDoclet.namespaceDoclet; + const node = JsDoc.formatDoclet(doclet); + assert.isTrue(node instanceof NamespaceNode, 'Did not create a namespace node from a namespace doclet.'); + }); + it('Formats a mixin doclet', () => { + const doclet = MixinDoclet.mixinDoclet; + const node = JsDoc.formatDoclet(doclet); + assert.isTrue(node instanceof MixinNode, 'Did not create a mixin node from a mixin doclet.'); + }); + it('Formats a module doclet', () => { + const doclet = ModuleDoclet.moduleDoclet; + const node = JsDoc.formatDoclet(doclet); + assert.isTrue(node instanceof ModuleNode, 'Did not create a module node from a module doclet.'); + }); + it('Throws an error for an unsupported doclet kind', () => { + const doclet = { + kind: 'somethingunknown' + }; + const formatDoclet = JsDoc.formatDoclet.bind(null, doclet); + assert.throws(formatDoclet, null, 'Should throw an error for an unsupported doclet kind.'); + }); + }); + +}); diff --git a/test/unit/lib/code-docs/jsdoc/nav.test.js b/test/unit/lib/code-docs/jsdoc/nav.test.js new file mode 100644 index 00000000..c7773107 --- /dev/null +++ b/test/unit/lib/code-docs/jsdoc/nav.test.js @@ -0,0 +1,132 @@ +'use strict'; + +const assert = require('proclaim'); +const sinon = require('sinon'); +const JsDocNav = require('../../../../../lib/code-docs/jsdoc/nav'); +const JsDoc = require('../../../../../lib/code-docs/jsdoc'); + +describe('lib/code-docs/jsdoc/nav', () => { + // Mock nodes (simplified) + // @see lib/code-docs/nodes/ + const classNode = { + kind: 'class', + group: 'classes', + label: 'Class', + name: 'TestDoclet', + longname: 'TestDoclet', + }; + const memberPropertyNode = { + kind: 'member', + group: 'properties', + label: 'Property', + name: 'name', + longname: 'TestDoclet#name', + memberof: 'TestDoclet' + }; + const memberFunctionNode = { + kind: 'function', + group: 'functions', + label: 'Method', + name: 'helloWorld', + longname: 'TestDoclet#helloWorld', + memberof: 'TestDoclet' + }; + const memberEventNode = { + kind: 'event', + group: 'events', + label: 'Event', + name: 'componentReady', + longname: 'TestDoclet#event:componentReady', + memberof: 'TestDoclet', + }; + const propertyNode = { + kind: 'member', + group: 'properties', + label: 'Property', + name: 'thing', + longname: 'thing', + }; + const functionNode = { + kind: 'function', + group: 'functions', + label: 'Function', + name: 'sayHello', + longname: 'sayHello' + }; + + it('creates a nav item for each class, with its properties and functions in a subnav', () => { + const jsDoc = new JsDoc([]); + sinon.stub(jsDoc, 'getNodes').callsFake(() => { + return [ + classNode, + memberPropertyNode, + memberFunctionNode + ]; + }); + sinon.stub(jsDoc, 'getNodesByTypeWithMembers').callsFake(() => { + const classWithMembers = classNode; + classWithMembers.properties = [memberPropertyNode]; + classWithMembers.functions = [memberFunctionNode]; + return { + classes: [classWithMembers] + }; + }); + const nav = JsDocNav.createNavigation(jsDoc); + // Check the class node is in the root of the nav. + const classSubNav = nav.find(subnav => subnav.title === classNode.name); + assert.equal(typeof classSubNav, 'object', 'Did not add the class node to the root of the nav by its name.'); + // Check the class node has a subnav with its constructor, properties, and methods. + const classConstructorSubNavItem = classSubNav.items.find(subnav => subnav.title.includes('Constructor')); + assert.equal(typeof classConstructorSubNavItem, 'object', 'Did not add the class\'s constructor to a subnav.'); + const classPropertiesSubNav = classSubNav.items.find(subnav => subnav.title.includes(memberPropertyNode.name)); + assert.equal(typeof classPropertiesSubNav, 'object', 'Did not add the class\'s property to a subnav.'); + const classFunctionsSubNav = classSubNav.items.find(subnav => subnav.title.includes(memberFunctionNode.name)); + assert.equal(typeof classFunctionsSubNav, 'object', 'Did not add the class\'s function to a subnav.'); + }); + + it('creates a "global" nav item for nodes which are not a member of another node', () => { + const jsDoc = new JsDoc([]); + sinon.stub(jsDoc, 'getNodes').callsFake(() => { + return [ + propertyNode, + functionNode + ]; + }); + sinon.stub(jsDoc, 'getNodesByTypeWithMembers').callsFake(() => { + return { + properties: [propertyNode], + functions: [functionNode] + }; + }); + const nav = JsDocNav.createNavigation(jsDoc); + // Check a global node is in the root of the nav with the global property and function. + const globalSubNav = nav.find(subnav => subnav.title === 'Global'); + assert.equal(typeof globalSubNav, 'object', 'Did not add global nodes to the root of the nav.'); + }); + + it('creates an "events" nav item for all event nodes', () => { + const jsDoc = new JsDoc([]); + sinon.stub(jsDoc, 'getNodes').callsFake(() => { + return [ + classNode, + memberEventNode, + ]; + }); + sinon.stub(jsDoc, 'getNodesByTypeWithMembers').callsFake(() => { + const classWithMembers = classNode; + classWithMembers.events = [memberEventNode]; + return { + classes: [classWithMembers] + }; + }); + const nav = JsDocNav.createNavigation(jsDoc); + // Check Event node is added to the root of nav reglardless of its "memberof" property. + const eventSubNav = nav.find(subnav => subnav.title === 'Events'); + assert.equal(typeof eventSubNav, 'object', 'Did not add the event node to the root of the nav.'); + assert.equal(eventSubNav.items.length, 1, 'Did not add all the event nodes to the root of the nav as expected.'); + // Check Event node is not added under the class + const classSubNav = nav.find(subnav => subnav.title === classNode.name); + const classEventItem = classSubNav.items.find(subnav => subnav.title.includes(memberEventNode.name)); + assert.equal(classEventItem, undefined, 'Events should not be added as a subnav item to the class they belong to.'); + }); +}); diff --git a/test/unit/lib/code-docs/jsdoc/nodes/base.test.js b/test/unit/lib/code-docs/jsdoc/nodes/base.test.js new file mode 100644 index 00000000..d223e9b7 --- /dev/null +++ b/test/unit/lib/code-docs/jsdoc/nodes/base.test.js @@ -0,0 +1,202 @@ +'use strict'; + +const assert = require('proclaim'); +const JsDocBaseNode = require('../../../../../../lib/code-docs/jsdoc/nodes/base'); +const Example = require('../../../../../../lib/code-docs/example'); + +describe('lib/code-docs/jsdoc/nodes/base', () => { + const comprehensiveDoclet = { + 'comment': '/**\n * A description of the function.\n * And {@link http://usejsdoc.org/tags-function.html a link}.\n * @param {string} worldName\n */', + 'meta': { + 'range': [ + 560, + 626 + ], + 'filename': 'test-doclet.js', + 'lineno': 15, + 'columnno': 0, + 'path': '/src/js', + 'code': { + 'id': 'astnode100000006', + 'name': 'TestDoclet.prototype.helloWorld', + 'type': 'FunctionDeclaration', + 'paramnames': [ + 'worldName' + ] + } + }, + 'description': 'A description of the function.\nAnd {@link http://usejsdoc.org/tags-function.html a link}.', + 'params': [ + { + 'type': { + 'names': [ + 'String' + ] + }, + 'description': 'Name of the world to say hello to e.g. Earth.', + 'name': 'worldName' + } + ], + 'returns': [ + { + 'type': { + 'names': [ + 'String' + ] + }, + 'description': 'Returns a hello.' + } + ], + 'name': 'helloWorld', + 'longname': 'TestDoclet#helloWorld', + 'kind': 'function', + 'scope': 'instance', + 'deprecated': 'See {@link http://example.com} for details.', + 'access': 'private', + 'examples': [ + '// a new test doclet\nnew TestDoclet();', + 'const example = "hi"
'));
+ });
+ it('formats code blocks with space before the language declaration for o-syntax-hightlight', () => {
+ const codeReadme = new Readme('``` scss\nbody: {background-color: hotpink;}\n```');
+ assert.ok(codeReadme.toString().includes('body: {background-color: hotpink;}
obt install
'));
+ });
+ });
+ describe('formatTables', () => {
+ it('formats tables for o-table "responsive scroll"', () => {
+ const codeReadme = new Readme('| example | number |\n|---------|--------|\n| 1 | one |\n| 2 | two |\n');
+ assert.ok(codeReadme.toString().includes('example | \nnumber | \n
---|---|
1 | \none | \n
2 | \ntwo | \n
FT-branded styles for test elements.
\n\nAdd content to o-test-component
:
<div class="o-test-component" data-o-component="o-test-component">\n <!-- My content -->\n</div>
For an example see the registry demos.
\nIn silent mode o-test-component
provides mixins for each part of the test component.
The oTestComponent
mixin will output all features of o-test-component
. Turn off silent mode to output all o-test-component
features using this mixin automatically.
$o-test-component-is-silent: false;\n@import 'o-test-component/main';
If your project does not need all o-test-component
features, you may reduce your project's CSS bundle size by using the following mixins to only output what you need.
o-test-component
provides some JavaScript to make things even better.
-<div class="o-test-component-original">\n+<div class="o-test-component">
If you have any questions or comments about this component, or need help using it, please either raise an issue, visit #ft-origami or email Origami Support.
\nThis software is published by the Financial Times under the MIT licence.
'); + }); + }); +}); diff --git a/test/unit/lib/code-docs/sassdoc/index.test.js b/test/unit/lib/code-docs/sassdoc/index.test.js new file mode 100644 index 00000000..9ca0c383 --- /dev/null +++ b/test/unit/lib/code-docs/sassdoc/index.test.js @@ -0,0 +1,143 @@ +'use strict'; + +const assert = require('proclaim'); +const SassDoc = require('../../../../../lib/code-docs/sassdoc'); +const VariableNode = require('../../../../../lib/code-docs/sassdoc/nodes/variable'); +const MixinNode = require('../../../../../lib/code-docs/sassdoc/nodes/mixin'); +const FunctionNode = require('../../../../../lib/code-docs/sassdoc/nodes/function'); +const MixinDoclet = require('../../../mock/code-docs/sassdoc/mixin'); +const FunctionDoclet = require('../../../mock/code-docs/sassdoc/function'); +const VariableDoclet = require('../../../mock/code-docs/sassdoc/variable'); + +describe('lib/code-docs/sassdoc/index', () => { + + it('has a groupNameAliases property which maps a group name of "undefined" to the component name', () => { + const testSassDoc = new SassDoc('o-test-component', 'master', []); + assert.deepEqual(testSassDoc.groupNameAliases, { + 'undefined': 'o-test-component', + }); + }); + + describe('supportedDoclets', () => { + it('returns an array of supported doclet kinds', () => { + assert.isTrue(Array.isArray(SassDoc.supportedDoclets()), 'Did not return an array.'); + assert.isTrue(SassDoc.supportedDoclets().includes('mixin'), 'Expected at least mixin doclets to be supported.'); + }); + }); + + describe('getNodesByKind', () => { + const testSassDoc = new SassDoc('o-test-component', 'master', [ + MixinDoclet.simpleDoclet, + MixinDoclet.comprehensiveDoclet, + FunctionDoclet.simpleDoclet, + FunctionDoclet.comprehensiveDoclet, + VariableDoclet.simpleDoclet, + VariableDoclet.comprehensiveDoclet + ]); + const nodes = testSassDoc.getNodesByKind(); + assert.isTrue(nodes.mixin[0] instanceof MixinNode, 'Did not return an object with a mixins property containing the formatted mixin node.'); + assert.isTrue(nodes.function[0] instanceof FunctionNode, 'Did not return an object with a functions property containing the formatted function node.'); + assert.isTrue(nodes.variable[0] instanceof VariableNode, 'Did not return an object with a variables property containing the formatted variable node.'); + }); + + describe('formatDoclet', () => { + it('formats a function doclet', () => { + const doclet = FunctionDoclet.comprehensiveDoclet; + const node = SassDoc.formatDoclet(doclet); + assert.isTrue(node instanceof FunctionNode, 'Did not create a function node from a function doclet.'); + }); + it('formats a mixin doclet', () => { + const doclet = MixinDoclet.comprehensiveDoclet; + const node = SassDoc.formatDoclet(doclet); + assert.isTrue(node instanceof MixinNode, 'Did not create a mixin node from a mixin doclet.'); + }); + it('formats a variable doclet', () => { + const doclet = VariableDoclet.comprehensiveDoclet; + const node = SassDoc.formatDoclet(doclet); + assert.isTrue(node instanceof VariableNode, 'Did not create a variable node from a variable doclet.'); + }); + it('throws an error for a placheolder doclet', () => { + const doclet = { + kind: 'placeholder' + }; + const formatDoclet = SassDoc.formatDoclet.bind(null, doclet); + assert.throws(formatDoclet, null, 'Should throw an error for an placeholder doclet kind.'); + }); + it('throws an error for an unsupported doclet', () => { + const doclet = { + kind: 'somethingunknown' + }; + const formatDoclet = SassDoc.formatDoclet.bind(null, doclet); + assert.throws(formatDoclet, null, 'Should throw an error for an unsupported doclet kind.'); + }); + }); + + describe('getNodes', () => { + const componentName = 'o-example'; + const brand = 'master'; + let doclet = {}; + + beforeEach(() => { + doclet = { + 'description': '', + 'context': { + 'type': 'function', + 'name': 'oTestComponentDouble', + }, + 'group': [ + 'undefined' + ], + 'access': 'public' + }; + }); + + it('removes doclets which do not have an access property', () => { + delete doclet.access; + assert.deepEqual(new SassDoc(componentName, brand, [doclet]).getNodes(), []); + }); + it('removes doclets which do not have a public access property', () => { + doclet.access = 'private'; + assert.deepEqual(new SassDoc(componentName, brand, [doclet]).getNodes(), []); + }); + it('removes doclets which are implicity private (name with underscore), ignoring the access property', () => { + doclet.context.name = '_oTestComponentDouble'; + assert.deepEqual(new SassDoc(componentName, brand, [doclet]).getNodes(), []); + }); + it('removes doclets which do not belong to the current brand', () => { + doclet.brand = { + 'supported': [ + 'internal' + ], + 'description': '' + }; + assert.deepEqual(new SassDoc(componentName, brand, [doclet]).getNodes(), []); + }); + it('does not remove doclets which define a brand property with no supported brands defined', () => { + doclet.brand = { + 'supported': [ + ], + 'description': '' + }; + assert.equal(new SassDoc(componentName, brand, [doclet]).getNodes().length, 1); + }); + it('removes placeholder doclets', () => { + doclet.context.type = 'placeholder'; + assert.deepEqual(new SassDoc(componentName, brand, [doclet]).getNodes(), []); + }); + it('removes unsupported doclets', () => { + doclet.context.type = 'notsupportedmadeup'; + assert.deepEqual(new SassDoc(componentName, brand, [doclet]).getNodes(), []); + }); + it('does not remove supported, public doclets which are for the current brand', () => { + assert.ok(new SassDoc(componentName, brand, [doclet]).getNodes()[0]); + }); + it('uses the component name instead of an "undefined" doclet group', () => { + assert.equal(new SassDoc(componentName, brand, [doclet]).getNodes()[0].group.name, componentName); + }); + it('returns array with formatted doclets', () => { + const nodes = new SassDoc(componentName, brand, [doclet]).getNodes(); + assert.isTrue(Array.isArray(nodes)); + assert.isTrue(nodes[0] instanceof FunctionNode); + }); + }); +}); diff --git a/test/unit/lib/code-docs/sassdoc/nav.test.js b/test/unit/lib/code-docs/sassdoc/nav.test.js new file mode 100644 index 00000000..a26b0c2c --- /dev/null +++ b/test/unit/lib/code-docs/sassdoc/nav.test.js @@ -0,0 +1,49 @@ +'use strict'; + +const assert = require('proclaim'); +const SassDocNav = require('../../../../../lib/code-docs/sassdoc/nav'); +const SassDoc = require('../../../../../lib/code-docs/sassdoc'); +const NavNode = require('../../../../../lib/code-docs/nav-node'); +const MixinDoclet = require('../../../mock/code-docs/sassdoc/mixin'); +const FunctionDoclet = require('../../../mock/code-docs/sassdoc/function'); +const VariableDoclet = require('../../../mock/code-docs/sassdoc/variable'); + +describe('lib/code-docs/sassdoc/nav', () => { + describe('createNavigation', () => { + const testSassDoc = new SassDoc('o-test-component', 'master', [ + FunctionDoclet.simpleDoclet, + FunctionDoclet.comprehensiveDoclet, + VariableDoclet.simpleDoclet, + VariableDoclet.comprehensiveDoclet, + MixinDoclet.simpleDoclet, + MixinDoclet.comprehensiveDoclet, + ]); + const testSassDocNav = SassDocNav.createNavigation(testSassDoc); + it('creates a subnav of the mixins', () => { + const mixinSubnav = testSassDocNav.find(navNode => navNode.title.toLowerCase().includes('mixin')); + assert.ok(mixinSubnav); + const expectedItemName = MixinDoclet.simpleDoclet.context.name; + const mixinNavItem = mixinSubnav.items.find(navNode => navNode.title.toLowerCase().includes(expectedItemName.toLowerCase())); + assert.isTrue(mixinNavItem instanceof NavNode); + }); + it('creates a subnav of the functions', () => { + const functionSubnav = testSassDocNav.find(navNode => navNode.title.toLowerCase().includes('function')); + assert.ok(functionSubnav); + const expectedItemName = FunctionDoclet.simpleDoclet.context.name; + const functionNavItem = functionSubnav.items.find(navNode => navNode.title.toLowerCase().includes(expectedItemName.toLowerCase())); + assert.isTrue(functionNavItem instanceof NavNode); + }); + it('creates a subnav of the variables', () => { + const variableSubnav = testSassDocNav.find(navNode => navNode.title.toLowerCase().includes('variable')); + assert.ok(variableSubnav); + const expectedItemName = VariableDoclet.simpleDoclet.context.name; + const variableNavItem = variableSubnav.items.find(navNode => navNode.title.toLowerCase().includes(expectedItemName.toLowerCase())); + assert.isTrue(variableNavItem instanceof NavNode); + }); + it('orders nav mixins then functions then variables', () => { + assert.ok(testSassDocNav[0].title.toLowerCase().includes('mixin')); + assert.ok(testSassDocNav[1].title.toLowerCase().includes('function')); + assert.ok(testSassDocNav[2].title.toLowerCase().includes('variable')); + }); + }); +}); diff --git a/test/unit/lib/code-docs/sassdoc/nodes/base.test.js b/test/unit/lib/code-docs/sassdoc/nodes/base.test.js new file mode 100644 index 00000000..a1a9b95f --- /dev/null +++ b/test/unit/lib/code-docs/sassdoc/nodes/base.test.js @@ -0,0 +1,262 @@ +'use strict'; + +const assert = require('proclaim'); +const SassDocBaseNode = require('../../../../../../lib/code-docs/sassdoc/nodes/base'); +const Example = require('../../../../../../lib/code-docs/example'); + +describe('lib/code-docs/sassdoc/nodes/base', () => { + + const comprehensiveDoclet = { + 'description': '', + 'commentRange': { + 'start': 17, + 'end': 23 + }, + 'context': { + 'type': 'function', + 'name': 'oTestComponentDouble', + 'code': '\n @return 2 * $scale;\n', + 'line': { + 'start': 24, + 'end': 26 + } + }, + 'group': [ + 'helpers' + ], + 'return': { + 'type': 'Number' + }, + 'example': [ + { + 'type': 'scss', + 'code': 'oTestComponentDouble(2) //4' + }, + { + 'type': 'scss', + 'code': 'oTestComponentDouble(10) //20' + } + ], + 'deprecated': 'This function has been replaced. Please contact Origami with any questions.', + 'access': 'public', + 'require': [], + 'file': { + 'path': 'src/scss/_functions.scss', + 'name': '_functions.scss' + }, + 'brand': { + 'supported': [ + 'master' + ], + 'description': 'Only master brand supported in this example' + }, + 'link': [ + { + 'url': 'https://www.google.co.uk/', + 'caption': '' + } + ], + 'parameter': [ + { + 'type': 'Number', + 'name': 'to-double', + 'description': 'The number to double' + }, + { + 'type': 'Bool', + 'name': 'example-param', + 'default': 'false', + 'description': '' + } + ], + 'aliased': [ + 'oTestComponentDoubler' + ] + }; + + const simpleDoclet = { + 'description': '', + 'commentRange': { + 'start': 17, + 'end': 23 + }, + 'context': { + 'type': 'function', + 'name': 'oTestComponentDouble', + 'code': '\n @return 2 * $scale;\n', + 'line': { + 'start': 24, + 'end': 26 + } + }, + 'group': [ + undefined + ], + 'access': 'public', + 'require': [], + 'file': { + 'path': 'src/scss/_functions.scss', + 'name': '_functions.scss' + } + }; + + it('creates a node to represent a complex SassDoc doclet with basic properties', () => { + const doclet = comprehensiveDoclet; + const node = new SassDocBaseNode(doclet); + assert.equal(node.name, 'oTestComponentDouble', 'Did not add expected name property.'); + assert.equal(node.description, '', 'Did not add expected description property.'); + assert.equal(node.longname, 'helpers-function-oTestComponentDouble', 'Did not add expected longname property.'); + assert.deepEqual(node.file, { + 'path': 'src/scss/_functions.scss', + 'name': '_functions.scss', + 'lineno': 24 + }, 'Did not add expected file property.'); + assert.deepEqual(node.group, { + key: 'helpers', + name: 'helpers' + }, 'Did not add expected group property.'); + assert.deepEqual(node.brand, { + description: 'Only master brand supported in this example', + supported: ['master'] + }, 'Did not add expected brand property.'); + assert.equal(node.deprecated, 'This function has been replaced. Please contact Origami with any questions.', 'Did not add expected deprecated property.'); + assert.deepEqual(node.links, [ + { + 'url': 'https://www.google.co.uk/', + 'caption': '' + } + ], 'Did not add expected links property.'); + assert.deepEqual(node.examples, [ + new Example('oTestComponentDouble(2) //4', 'scss'), + new Example('oTestComponentDouble(10) //20', 'scss') + ], 'Did not add expected examples property.'); + }); + + it('creates a node to represent a simple SassDoc doclet with basic properties', () => { + const doclet = simpleDoclet; + const node = new SassDocBaseNode(doclet); + assert.equal(node.name, 'oTestComponentDouble', 'Did not add expected name property.'); + assert.equal(node.description, '', 'Did not add expected description property.'); + assert.equal(node.longname, 'function-oTestComponentDouble', 'Did not add expected longname property.'); + assert.deepEqual(node.file, { + 'path': 'src/scss/_functions.scss', + 'name': '_functions.scss', + 'lineno': 24 + }, 'Did not add expected file property.'); + assert.deepEqual(node.group, { + key: '', + name: '' + }, 'Did not add expected group property.'); + assert.deepEqual(node.links, [], 'Expect an empty links array.'); + assert.deepEqual(node.examples, [], 'Expect an empty examples array.'); + assert.equal(node.brand, undefined, 'Did not expect a brand property on the simple SassDoc example.'); + assert.equal(node.deprecated, undefined, 'Did not expect a deprecation notice on the simple SassDoc example.'); + }); + + it('uses groupName for the node group property, if the groupName is provided by SassDoc extras', () => { + // @see https://github.com/SassDoc/sassdoc-extras/blob/2.4.3/src/groupName.js#L30 + const simpleExampleDoclet = simpleDoclet; + simpleExampleDoclet.groupName = { + // group : label + undefined: 'o-example' + }; + const simpleNode = new SassDocBaseNode(simpleExampleDoclet); + assert.deepEqual(simpleNode.group, { + key: '', + name: 'o-example' + }); + + const comprehensiveExampleDoclet = comprehensiveDoclet; + comprehensiveExampleDoclet.groupName = { + // group : label + 'helpers': 'Utils' + }; + const comprehensiveNode = new SassDocBaseNode(comprehensiveExampleDoclet); + assert.deepEqual(comprehensiveNode.group, { + key: 'helpers', + name: 'Utils' + }); + }); + + it('does not add a brand property if no brands are defined', () => { + const doclet = simpleDoclet; + doclet.brand = { + 'supported': [], + 'description': 'Have not specified brands.' + }; + const node = new SassDocBaseNode(doclet); + assert.equal(node.brand, undefined); + }); + + describe('addAliases', () => { + it('Adds aliases to the node', () => { + const doclet = comprehensiveDoclet; + const node = new SassDocBaseNode(doclet); + node.addAliases(doclet); + assert.deepEqual(node.aliases, ['oTestComponentDoubler'], 'Did not add expected aliases property.'); + }); + + it('Adds an empty aliases array for doclets which have no aliases', () => { + const doclet = simpleDoclet; + const node = new SassDocBaseNode(doclet); + node.addAliases(doclet); + assert.deepEqual(node.aliases, []); + }); + }); + + describe('addParameters', () => { + it('Adds parameters to the node', () => { + const doclet = comprehensiveDoclet; + const node = new SassDocBaseNode(doclet); + node.addParameters(doclet); + assert.deepEqual(node.parameters, [{ + name: 'to-double', + type: ['Number'], + description: 'The number to double', + optional: false + }, { + name: 'example-param', + type: ['Bool'], + description: '', + default: 'false', + optional: true + }], 'Did not add expected parameters property.'); + }); + + it('Adds an empty parameters array for doclets which have no parameters', () => { + const doclet = simpleDoclet; + const node = new SassDocBaseNode(doclet); + node.addParameters(doclet); + assert.deepEqual(node.parameters, []); + }); + + it('Names any unnamed parameter', () => { + const doclet = comprehensiveDoclet; + // Add mock parameter with no name + doclet.parameter = []; + doclet.parameter.push({ + 'type': 'Bool', + 'default': 'false', + 'description': 'Some unname parameter.' + }); + const node = new SassDocBaseNode(doclet); + node.addParameters(doclet); + assert.deepEqual(node.parameters, [{ + name: '[param #1]', // name added + type: ['Bool'], + default: 'false', + optional: true, + description: 'Some unname parameter.' + }]); + }); + }); + + describe('parseTypes', () => { + it('Transform a string of SassDoc types to an array.', () => { + const doclet = comprehensiveDoclet; + const node = new SassDocBaseNode(doclet); + assert.deepEqual(node.parseTypes('Color | Null'), ['Color', 'Null']); + assert.deepEqual(node.parseTypes(''), []); + }); + }); +}); diff --git a/test/unit/lib/code-docs/sassdoc/nodes/function.test.js b/test/unit/lib/code-docs/sassdoc/nodes/function.test.js new file mode 100644 index 00000000..a06b20c9 --- /dev/null +++ b/test/unit/lib/code-docs/sassdoc/nodes/function.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const assert = require('proclaim'); +const SassDocFunctionNode = require('../../../../../../lib/code-docs/sassdoc/nodes/function'); +const FunctionDoclet = require('../../../../mock/code-docs/sassdoc/function'); + +describe('lib/code-docs/sassdoc/nodes/function', () => { + const comprehensiveNode = new SassDocFunctionNode(FunctionDoclet.comprehensiveDoclet); + const simpleNode = new SassDocFunctionNode(FunctionDoclet.simpleDoclet); + it('creates a function node from a function doclet', () => { + assert.isTrue(simpleNode instanceof SassDocFunctionNode); + assert.isTrue(comprehensiveNode instanceof SassDocFunctionNode); + }); + it('has a kind property', () => { + assert.equal(simpleNode.kind, 'function'); + assert.equal(comprehensiveNode.kind, 'function'); + }); + it('has a return property if the function doclet defines one', () => { + assert.deepEqual(comprehensiveNode.return.types, ['String', 'Null']); + assert.equal(comprehensiveNode.return.description, 'A made-up sentence.'); + }); + it('has a blank return property if the function doclet defines no return value', () => { + assert.equal(simpleNode.return.types, ''); + assert.equal(simpleNode.return.description, ''); + }); + it('has a blank aliases property if the function doclet defines no alias', () => { + assert.deepEqual(comprehensiveNode.aliases, [ + 'oTestComponentDoTell' + ]); + }); + it('has an aliases property if the function doclet has an alias', () => { + assert.deepEqual(simpleNode.aliases, []); + }); + it('has a parameters property if the function doclet defines parameters', () => { + assert.deepEqual(comprehensiveNode.parameters, [ + { + 'type': ['Bool'], + 'name': 'keep-quiet', + 'default': 'false', + 'optional': true, + 'description': 'Whether to tell (default) or keep quiet.' + }, + { + 'type': ['Bool'], + 'name': 'truth', + 'default': 'true', + 'optional': true, + 'description': 'Whether to tell the truth (default) or lie.' + } + ]); + }); + it('has has blank parameters property if the function doclet has no parameters', () => { + assert.deepEqual(simpleNode.parameters, []); + }); +}); diff --git a/test/unit/lib/code-docs/sassdoc/nodes/mixin.test.js b/test/unit/lib/code-docs/sassdoc/nodes/mixin.test.js new file mode 100644 index 00000000..59d3f06f --- /dev/null +++ b/test/unit/lib/code-docs/sassdoc/nodes/mixin.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const assert = require('proclaim'); +const SassDocMixinNode = require('../../../../../../lib/code-docs/sassdoc/nodes/mixin'); +const MixinDoclet = require('../../../../mock/code-docs/sassdoc/mixin'); + +describe('lib/code-docs/sassdoc/nodes/mixin', () => { + const comprehensiveNode = new SassDocMixinNode(MixinDoclet.comprehensiveDoclet); + const simpleNode = new SassDocMixinNode(MixinDoclet.simpleDoclet); + it('creates a mixin node from a mixin doclet', () => { + assert.isTrue(simpleNode instanceof SassDocMixinNode); + assert.isTrue(comprehensiveNode instanceof SassDocMixinNode); + }); + it('has an output property', () => { + assert.equal(simpleNode.output, ''); + assert.equal(comprehensiveNode.output, 'Modifiying, cosmetic styles'); + }); + it('has a content property', () => { + assert.equal(simpleNode.content, ''); + assert.equal(comprehensiveNode.content, 'Extra styles to modify test component theme.'); + }); + it('has a kind property', () => { + assert.equal(simpleNode.kind, 'mixin'); + assert.equal(comprehensiveNode.kind, 'mixin'); + }); + it('has a blank aliases property if the mixin doclet defines no alias', () => { + assert.deepEqual(comprehensiveNode.aliases, [ + 'oTestComponentCustomTheme' + ]); + }); + it('has an aliases property if the mixin doclet has an alias', () => { + assert.deepEqual(simpleNode.aliases, []); + }); + it('has a parameters property if the mixin doclet defines parameters', () => { + assert.deepEqual(comprehensiveNode.parameters, [ + { + 'type': ['Map'], + 'name': 'theme', + 'description': 'Apply a custom theme to the component. Theme keys include \'primary-color\' and \'secondary-color\' (see example).', + 'optional': false + } + ]); + }); + it('has a blank parameters property if the mixin doclet has no parameters', () => { + assert.deepEqual(simpleNode.parameters, []); + }); +}); diff --git a/test/unit/lib/code-docs/sassdoc/nodes/variable.test.js b/test/unit/lib/code-docs/sassdoc/nodes/variable.test.js new file mode 100644 index 00000000..75184a38 --- /dev/null +++ b/test/unit/lib/code-docs/sassdoc/nodes/variable.test.js @@ -0,0 +1,32 @@ +'use strict'; + +const assert = require('proclaim'); +const SassDocVariableNode = require('../../../../../../lib/code-docs/sassdoc/nodes/variable'); +const VariableDoclet = require('../../../../mock/code-docs/sassdoc/variable'); + +describe('lib/code-docs/sassdoc/nodes/variable', () => { + const comprehensiveNode = new SassDocVariableNode(VariableDoclet.comprehensiveDoclet); + const simpleNode = new SassDocVariableNode(VariableDoclet.simpleDoclet); + it('creates a variable node from a variable doclet', () => { + assert.isTrue(simpleNode instanceof SassDocVariableNode); + assert.isTrue(comprehensiveNode instanceof SassDocVariableNode); + }); + it('has a kind property', () => { + assert.equal(simpleNode.kind, 'variable'); + assert.equal(comprehensiveNode.kind, 'variable'); + }); + it('adds types property if the variable doclet defines its type', () => { + assert.deepEqual(comprehensiveNode.types, ['Color']); + }); + it('has a blank types property if the variable doclet defines no type', () => { + assert.deepEqual(simpleNode.types, []); + }); + it('has a blank aliases property if the variable doclet defines no alias', () => { + assert.deepEqual(comprehensiveNode.aliases, [ + 'o-test-component-primary-color' + ]); + }); + it('has an aliases property if the variable doclet has an alias', () => { + assert.deepEqual(simpleNode.aliases, []); + }); +}); diff --git a/test/unit/mock/code-docs/jsdoc/class.js b/test/unit/mock/code-docs/jsdoc/class.js new file mode 100644 index 00000000..af1ef223 --- /dev/null +++ b/test/unit/mock/code-docs/jsdoc/class.js @@ -0,0 +1,62 @@ +'use strict'; + +module.exports.constructorDoclet = { + 'comment': '/**\n * An example constructor within a module.\n * @constructor\n * @example\n * const myIncrementer = new incrementer();\n * myIncrementer.increment(4); // 4\n * myIncrementer.increment(4); // 8\n * myIncrementer.increment(1); // 9\n */', + 'meta': { + 'range': [ + 698, + 996 + ], + 'filename': 'complex-module.js', + 'lineno': 28, + 'columnno': 0, + 'path': '/src/js', + 'code': { + 'id': 'astnode100000215', + 'name': 'module.exports.incrementer', + 'type': 'FunctionExpression', + 'value': 'incrementer', + 'paramnames': [] + }, + 'vars': { + 'this.num': 'module:ComplexModule.incrementer#num', + 'this.increment': 'module:ComplexModule.incrementer#increment', + '': null + } + }, + 'description': 'An example constructor within a module.', + 'kind': 'class', + 'examples': [ + 'const myIncrementer = new incrementer();\nmyIncrementer.increment(4); // 4' + ], + 'name': 'incrementer', + 'longname': 'module:ComplexModule.incrementer', + 'memberof': 'module:ComplexModule', + 'scope': 'static' +}; + +module.exports.classDeclarationDoclet = { + 'comment': '/**\n * Class representing a domesticated animal.\n */', + 'meta': { + 'range': [ + 370, + 509 + ], + 'filename': 'class.js', + 'lineno': 22, + 'columnno': 0, + 'path': '/src/js', + 'code': { + 'id': 'astnode100000095', + 'name': 'Pet', + 'type': 'ClassDeclaration', + 'paramnames': [] + } + }, + 'classdesc': 'Class representing a domesticated animal.', + 'name': 'Pet', + 'longname': 'Pet', + 'kind': 'class', + 'scope': 'global', + 'params': [] +}; diff --git a/test/unit/mock/code-docs/jsdoc/event.js b/test/unit/mock/code-docs/jsdoc/event.js new file mode 100644 index 00000000..b32ca6fe --- /dev/null +++ b/test/unit/mock/code-docs/jsdoc/event.js @@ -0,0 +1,50 @@ +'use strict'; + +module.exports.eventDoclet = { + 'comment': '/**\n\t * Snowball event.\n\t *\n\t * @event Hurl#snowball\n\t * @type {object}\n\t * @property {boolean} detail.isPacked - Indicates whether the snowball is tightly packed.\n\t */', + 'meta': { + 'filename': 'event.js', + 'lineno': 15, + 'columnno': 1, + 'path': '/src/js', + 'code': {} + }, + 'description': 'Snowball event.', + 'kind': 'event', + 'name': 'snowball', + 'type': { + 'names': [ + 'object' + ] + }, + 'properties': [ + { + 'type': { + 'names': [ + 'boolean' + ] + }, + 'description': 'Indicates whether the snowball is tightly packed.', + 'name': 'detail.isPacked' + } + ], + 'memberof': 'Hurl', + 'longname': 'Hurl#event:snowball', + 'scope': 'instance' +}; + +module.exports.simpleEventDoclet = { + 'comment': '/**\n\t * Snowball event.\n\t *\n\t * @event Hurl#snowball\n\t */', + 'meta': { + 'filename': 'event.js', + 'lineno': 15, + 'columnno': 1, + 'path': '/src/js', + 'code': {} + }, + 'kind': 'event', + 'name': 'snowball', + 'memberof': 'Hurl', + 'longname': 'Hurl#event:snowball', + 'scope': 'instance' +}; diff --git a/test/unit/mock/code-docs/jsdoc/function.js b/test/unit/mock/code-docs/jsdoc/function.js new file mode 100644 index 00000000..e95fcca1 --- /dev/null +++ b/test/unit/mock/code-docs/jsdoc/function.js @@ -0,0 +1,68 @@ +'use strict'; + +module.exports.globalFunctionDoclet = { + 'comment': '/**\n * @deprecated Use {@link Person#sayHello} instead.\n * @param {string} name\n * To make a human says hello, announcing their name.\n */', + 'meta': { + 'range': [ + 138, + 213 + ], + 'filename': 'deprecated.js', + 'lineno': 6, + 'columnno': 0, + 'path': '/src/js', + 'code': { + 'id': 'astnode100000250', + 'name': 'humanSayHello', + 'type': 'FunctionDeclaration', + 'paramnames': [ + 'name' + ] + } + }, + 'deprecated': 'Use {@link Person#sayHello} instead.', + 'params': [ + { + 'type': { + 'names': [ + 'string' + ] + }, + 'description': 'To make a human says hello, announcing their name.', + 'name': 'name' + } + ], + 'name': 'humanSayHello', + 'longname': 'humanSayHello', + 'kind': 'function', + 'scope': 'global' +}; + +module.exports.instanceFunctionDocletWhichFires = { + 'comment': '/**\n * Throw a snowball.\n *\n * @fires Hurl#snowball\n */', + 'meta': { + 'range': [ + 164, + 484 + ], + 'filename': 'event.js', + 'lineno': 14, + 'columnno': 0, + 'path': '/src/js', + 'code': { + 'id': 'astnode100000269', + 'name': 'Hurl.prototype.snowball', + 'type': 'FunctionExpression', + 'paramnames': [] + } + }, + 'description': 'Throw a snowball.', + 'fires': [ + 'Hurl#event:snowball' + ], + 'name': 'snowball', + 'longname': 'Hurl#snowball', + 'kind': 'function', + 'memberof': 'Hurl', + 'scope': 'instance' +}; diff --git a/test/unit/mock/code-docs/jsdoc/mixin.js b/test/unit/mock/code-docs/jsdoc/mixin.js new file mode 100644 index 00000000..a21827c5 --- /dev/null +++ b/test/unit/mock/code-docs/jsdoc/mixin.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports.mixinDoclet = { + 'comment': '/**\n *\n * Test the JSDoc mixin tag.\n * http://usejsdoc.org/tags-mixin.html\n *\n * This provides methods used for event handling. It\'s not meant to\n * be used directly.\n *\n * @mixin\n */', + 'meta': { + 'range': [ + 190, + 782 + ], + 'filename': 'mixin.js', + 'lineno': 11, + 'columnno': 6, + 'path': '/src/js', + 'code': { + 'id': 'astnode100000303', + 'name': 'Eventful', + 'type': 'ObjectExpression', + 'value': '{"on":"","fire":""}' + } + }, + 'description': 'Test the JSDoc mixin tag.\nhttp://usejsdoc.org/tags-mixin.html\n\nThis provides methods used for event handling. It\'s not meant to\nbe used directly.', + 'kind': 'mixin', + 'name': 'Eventful', + 'longname': 'Eventful', + 'scope': 'global', + 'params': [] +}; diff --git a/test/unit/mock/code-docs/jsdoc/module.js b/test/unit/mock/code-docs/jsdoc/module.js new file mode 100644 index 00000000..479f859d --- /dev/null +++ b/test/unit/mock/code-docs/jsdoc/module.js @@ -0,0 +1,29 @@ +'use strict'; + +module.exports.moduleDoclet = { + 'comment': '/**\n * Tests a single export module.\n * @module SimpleModule\n * @see module:example-module for an example of multi export module documentation.\n * @returns {String} Returns a fixed string.\n */', + 'meta': { + 'filename': 'simple-module.js', + 'lineno': 1, + 'columnno': 0, + 'path': '/src/js', + 'code': {} + }, + 'description': 'Tests a single export module.', + 'kind': 'module', + 'name': 'SimpleModule', + 'see': [ + 'module:example-module for an example of multi export module documentation.' + ], + 'returns': [ + { + 'type': { + 'names': [ + 'String' + ] + }, + 'description': 'Returns a fixed string.' + } + ], + 'longname': 'module:SimpleModule' +}; diff --git a/test/unit/mock/code-docs/jsdoc/namespace.js b/test/unit/mock/code-docs/jsdoc/namespace.js new file mode 100644 index 00000000..34cbf4d3 --- /dev/null +++ b/test/unit/mock/code-docs/jsdoc/namespace.js @@ -0,0 +1,16 @@ +'use strict'; + +module.exports.namespaceDoclet = { + 'comment': '/** @namespace window */', + 'meta': { + 'filename': 'namespace.js', + 'lineno': 1, + 'columnno': 0, + 'path': '/src/js', + 'code': {} + }, + 'kind': 'namespace', + 'name': 'window', + 'longname': 'window', + 'scope': 'global' +}; diff --git a/test/unit/mock/code-docs/jsdoc/property.js b/test/unit/mock/code-docs/jsdoc/property.js new file mode 100644 index 00000000..7cc79cfc --- /dev/null +++ b/test/unit/mock/code-docs/jsdoc/property.js @@ -0,0 +1,63 @@ +'use strict'; + +module.exports.memberDoclet = { + 'comment': '/** @member {string} */', + 'meta': { + 'range': [ + 162, + 178 + ], + 'filename': 'class.js', + 'lineno': 9, + 'columnno': 1, + 'path': '/src/js', + 'code': { + 'id': 'astnode100000070', + 'name': 'this.name', + 'type': 'Identifier', + 'value': 'name', + 'paramnames': [] + } + }, + 'kind': 'member', + 'type': { + 'names': [ + 'string' + ] + }, + 'name': 'name', + 'longname': 'Person#name', + 'memberof': 'Person', + 'scope': 'instance' +}; + +module.exports.constantDoclet = { + 'comment': '/**\n * A global variable called \'foo\'.\n * http://usejsdoc.org/tags-member.html\n * @type {number}\n */', + 'meta': { + 'range': [ + 107, + 115 + ], + 'filename': 'main.js', + 'lineno': 6, + 'columnno': 6, + 'path': '', + 'code': { + 'id': 'astnode100000003', + 'name': 'foo', + 'type': 'Literal', + 'value': 42 + } + }, + 'description': 'A global variable called \'foo\'.\nhttp://usejsdoc.org/tags-member.html', + 'type': { + 'names': [ + 'number' + ] + }, + 'name': 'foo', + 'longname': 'foo', + 'kind': 'constant', + 'scope': 'global', + 'params': [] +}; diff --git a/test/unit/mock/code-docs/readme/readme.md b/test/unit/mock/code-docs/readme/readme.md new file mode 100644 index 00000000..a210fcd4 --- /dev/null +++ b/test/unit/mock/code-docs/readme/readme.md @@ -0,0 +1,73 @@ +# o-test-component [![CircleCI](https://circleci.com/gh/Financial-Times/o-test-component.png?style=shield&circle-token=8d39afee1e3c3b1f586770034db9673b791cb4f8)](https://circleci.com/gh/Financial-Times/o-test-component) + +FT-branded styles for test elements. + +- [Usage](#usage) + - [Markup](#markup) + - [Sass](#sass) + - [JavaScript](#javascript) +- [Troubleshooting](#troubleshooting) +- [Migration guide](#migration-guide) +- [Contact](#contact) +- [Licence](#licence) + + +## Usage + +### Markup + +Add content to `o-test-component`: + +```html +It looks like JsDocs for {{ component.name }}@{{ component.version }} are missing or formatted incorrectly.
+
For component documentation see the readme.
+ {{/unless}} + + {{!-- {{ Output global JSDoc nodes with links to their member nodes. }} --}} + {{#each jsDocByTypeWithMembers }} + {{#each this}} + {{> jsdoc/jsdoc-node }} + {{/each}} + {{/each}} + + {{!-- {{ Output member nodes so they can be viewed in more detail. }} --}} + {{#each memberJsDocs}} + {{> jsdoc/jsdoc-node }} + {{/each}} + + {{!-- {{ Output default node to display before selection is made. }} --}} + {{#if defaultNode}} + {{> jsdoc/jsdoc-node defaultNode default=true }} + {{/if}} +{{ this.code }}
+ parameter | +type | +default | +description | +
---|---|---|---|
{{name}}{{#if optional}} (optional){{/if}} | ++ {{#each type}} + {{ this }} + {{#unless @last}} | {{/unless}} + {{/each}} + | +{{default}} | +{{description}} | +
{{component.description}}
{{{display.highlightedHTML}}}
diff --git a/views/partials/header/nav.html b/views/partials/header/nav.html
index d1c2eed0..5e0ddb32 100644
--- a/views/partials/header/nav.html
+++ b/views/partials/header/nav.html
@@ -19,3 +19,52 @@
{{description}}
+{{/if}} + +{{#if constructor}} + {{#if constructor.description}} +{{constructor.description}}
+ {{/if}} + + {{#if constructor.parameters}} + {{> codedocs/nodes/sections/parameters-table constructor.parameters }} + {{/if}} +{{/if}} + +{{#if functions}} + {{> jsdoc/nodes/sections/functions functions }} +{{/if}} + +{{#if properties}} + {{> jsdoc/nodes/sections/properties properties }} +{{/if}} + +{{#if extends}} + {{> jsdoc/nodes/sections/extends extends }} +{{/if}} + +{{#if fires}} + {{> jsdoc/nodes/sections/fires-events fires }} +{{/if}} + +{{#if constructor}} + {{#if constructor.examples}} + {{> codedocs/nodes/sections/examples constructor.examples }} + {{/if}} +{{/if}} diff --git a/views/partials/jsdoc/nodes/event-node.html b/views/partials/jsdoc/nodes/event-node.html new file mode 100644 index 00000000..2a4ac7af --- /dev/null +++ b/views/partials/jsdoc/nodes/event-node.html @@ -0,0 +1,18 @@ +{{description}}
+{{/if}} + +{{#if properties}} + {{> jsdoc/nodes/sections/properties-table properties }} +{{/if}} + +{{#if examples}} + {{> codedocs/nodes/sections/examples this }} +{{/if}} + diff --git a/views/partials/jsdoc/nodes/function-node.html b/views/partials/jsdoc/nodes/function-node.html new file mode 100644 index 00000000..88c405cc --- /dev/null +++ b/views/partials/jsdoc/nodes/function-node.html @@ -0,0 +1,23 @@ +{{> codedocs/nodes/sections/deprecation-warning }} + +{{description}}
+{{/if}} + +{{#if parameters}} + {{> codedocs/nodes/sections/parameters-table parameters }} +{{/if}} + +{{#if returns}} + {{> jsdoc/nodes/sections/returns returns }} +{{/if}} + +{{#if fires}} + {{> jsdoc/nodes/sections/fires-events fires }} +{{/if}} + +{{#if functions}} + {{> jsdoc/nodes/sections/functions functions }} +{{/if}} + +{{#if classes}} + {{> jsdoc/nodes/sections/classes classes }} +{{/if}} + +{{#if properties}} + {{> jsdoc/nodes/sections/properties properties }} +{{/if}} + +{{#if extends}} + {{> jsdoc/nodes/sections/extends extends }} +{{/if}} + +{{#if examples}} + {{> codedocs/nodes/sections/examples examples }} +{{/if}} diff --git a/views/partials/jsdoc/nodes/mixin-node.html b/views/partials/jsdoc/nodes/mixin-node.html new file mode 100644 index 00000000..2d19ec20 --- /dev/null +++ b/views/partials/jsdoc/nodes/mixin-node.html @@ -0,0 +1,2 @@ +{{!-- {{ A mixin may take one of many forms. }} --}} +{{> jsdoc/nodes/generic this }} diff --git a/views/partials/jsdoc/nodes/module-node.html b/views/partials/jsdoc/nodes/module-node.html new file mode 100644 index 00000000..76489bce --- /dev/null +++ b/views/partials/jsdoc/nodes/module-node.html @@ -0,0 +1,2 @@ +{{!-- {{ A module may take one of many forms. }} --}} +{{> jsdoc/nodes/generic this }} diff --git a/views/partials/jsdoc/nodes/namespace-node.html b/views/partials/jsdoc/nodes/namespace-node.html new file mode 100644 index 00000000..bf155d3c --- /dev/null +++ b/views/partials/jsdoc/nodes/namespace-node.html @@ -0,0 +1,17 @@ +{{description}}
+{{/if}} + +{{#if functions}} + {{> jsdoc/nodes/sections/functions functions }} +{{/if}} + +{{#if classes}} + {{> jsdoc/nodes/sections/classes classes }} +{{/if}} + +{{#if properties}} + {{> jsdoc/nodes/sections/properties properties }} +{{/if}} diff --git a/views/partials/jsdoc/nodes/property-node.html b/views/partials/jsdoc/nodes/property-node.html new file mode 100644 index 00000000..3ff1a5a3 --- /dev/null +++ b/views/partials/jsdoc/nodes/property-node.html @@ -0,0 +1,14 @@ +{{description}}
+{{/if}} diff --git a/views/partials/jsdoc/nodes/sections/classes.html b/views/partials/jsdoc/nodes/sections/classes.html new file mode 100644 index 00000000..7f3abc74 --- /dev/null +++ b/views/partials/jsdoc/nodes/sections/classes.html @@ -0,0 +1,13 @@ +{{#if this}} +property | +type | +description | +
---|---|---|
{{name}} | ++ {{#each type}} {{ this }} {{#unless @last}} | {{/unless}} {{/each}} + | +{{description}} | +