/** * Copyright 2017 Google Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const Documentation = require('./Documentation'); const commonmark = require('commonmark'); class MDOutline { /** * @param {!Page} page * @param {string} text * @return {!MDOutline} */ static async create(page, text) { // Render markdown as HTML. const reader = new commonmark.Parser(); const parsed = reader.parse(text); const writer = new commonmark.HtmlRenderer(); const html = writer.render(parsed); page.on('console', msg => { console.log(msg.text()); }); // Extract headings. await page.setContent(html); const {classes, errors} = await page.evaluate(() => { const classes = []; const errors = []; const headers = document.body.querySelectorAll('h3'); for (let i = 0; i < headers.length; i++) { const fragment = extractSiblingsIntoFragment(headers[i], headers[i + 1]); classes.push(parseClass(fragment)); } return {classes, errors}; /** * @param {HTMLLIElement} element */ function parseProperty(element) { const clone = element.cloneNode(true); const ul = clone.querySelector(':scope > ul'); const str = parseComment(extractSiblingsIntoFragment(clone.firstChild, ul)); const name = str.substring(0, str.indexOf('<')).replace(/\`/g, '').trim(); const type = findType(str); const properties = []; const comment = str.substring(str.indexOf('<') + type.length + 2).trim(); // Strings have enum values instead of properties if (!type.includes('string')) { for (const childElement of element.querySelectorAll(':scope > ul > li')) { const property = parseProperty(childElement); property.required = property.comment.includes('***required***'); properties.push(property); } } return { name, type, comment, properties }; } /** * @param {string} str * @return {string} */ function findType(str) { const start = str.indexOf('<') + 1; let count = 1; for (let i = start; i < str.length; i++) { if (str[i] === '<') count++; if (str[i] === '>') count--; if (!count) return str.substring(start, i); } return 'unknown'; } /** * @param {DocumentFragment} content */ function parseClass(content) { const members = []; const headers = content.querySelectorAll('h4'); const name = content.firstChild.textContent; let extendsName = null; let commentStart = content.firstChild.nextSibling; const extendsElement = content.querySelector('ul'); if (extendsElement && extendsElement.textContent.trim().startsWith('extends:')) { commentStart = extendsElement.nextSibling; extendsName = extendsElement.querySelector('a').textContent; } const comment = parseComment(extractSiblingsIntoFragment(commentStart, headers[0])); for (let i = 0; i < headers.length; i++) { const fragment = extractSiblingsIntoFragment(headers[i], headers[i + 1]); members.push(parseMember(fragment)); } return { name, comment, extendsName, members }; } /** * @param {Node} content */ function parseComment(content) { for (const code of content.querySelectorAll('pre > code')) code.replaceWith('```' + code.className.substring('language-'.length) + '\n' + code.textContent + '```'); for (const code of content.querySelectorAll('code')) code.replaceWith('`' + code.textContent + '`'); for (const strong of content.querySelectorAll('strong')) strong.replaceWith('**' + parseComment(strong) + '**'); return content.textContent.trim(); } /** * @param {string} name * @param {DocumentFragment} content */ function parseMember(content) { const name = content.firstChild.textContent; const args = []; let returnType = null; const paramRegex = /^\w+\.[\w$]+\((.*)\)$/; const matches = paramRegex.exec(name) || ['', '']; const parameters = matches[1]; const optionalStartIndex = parameters.indexOf('['); const optinalParamsStr = optionalStartIndex !== -1 ? parameters.substring(optionalStartIndex).replace(/[\[\]]/g, '') : ''; const optionalparams = new Set(optinalParamsStr.split(',').filter(x => x).map(x => x.trim())); const ul = content.querySelector('ul'); for (const element of content.querySelectorAll('h4 + ul > li')) { if (element.matches('li') && element.textContent.trim().startsWith('<')) { returnType = parseProperty(element); } else if (element.matches('li') && element.firstChild.matches && element.firstChild.matches('code')) { const property = parseProperty(element); property.required = !optionalparams.has(property.name); args.push(property); } else if (element.matches('li') && element.firstChild.nodeType === Element.TEXT_NODE && element.firstChild.textContent.toLowerCase().startsWith('return')) { returnType = parseProperty(element); const expectedText = 'returns: '; let actualText = element.firstChild.textContent; let angleIndex = actualText.indexOf('<'); let spaceIndex = actualText.indexOf(' '); angleIndex = angleIndex === -1 ? actualText.length : angleIndex; spaceIndex = spaceIndex === -1 ? actualText.length : spaceIndex + 1; actualText = actualText.substring(0, Math.min(angleIndex, spaceIndex)); if (actualText !== expectedText) errors.push(`${name} has mistyped 'return' type declaration: expected exactly '${expectedText}', found '${actualText}'.`); } } const comment = parseComment(extractSiblingsIntoFragment(ul ? ul.nextSibling : content)); return { name, args, returnType, comment }; } /** * @param {!Node} fromInclusive * @param {!Node} toExclusive * @return {!DocumentFragment} */ function extractSiblingsIntoFragment(fromInclusive, toExclusive) { const fragment = document.createDocumentFragment(); let node = fromInclusive; while (node && node !== toExclusive) { const next = node.nextSibling; fragment.appendChild(node); node = next; } return fragment; } }); return new MDOutline(classes, errors); } constructor(classes, errors) { this.classes = []; this.errors = errors; const classHeading = /^class: (\w+)$/; const constructorRegex = /^new (\w+)\((.*)\)$/; const methodRegex = /^(\w+)\.([\w$]+)\((.*)\)$/; const propertyRegex = /^(\w+)\.(\w+)$/; const eventRegex = /^event: '(\w+)'$/; let currentClassName = null; let currentClassMembers = []; let currentClassComment = ''; let currentClassExtends = null; for (const cls of classes) { const match = cls.name.match(classHeading); if (!match) continue; currentClassName = match[1]; currentClassComment = cls.comment; currentClassExtends = cls.extendsName; for (const member of cls.members) { if (constructorRegex.test(member.name)) { const match = member.name.match(constructorRegex); handleMethod.call(this, member, match[1], 'constructor', match[2]); } else if (methodRegex.test(member.name)) { const match = member.name.match(methodRegex); handleMethod.call(this, member, match[1], match[2], match[3]); } else if (propertyRegex.test(member.name)) { const match = member.name.match(propertyRegex); handleProperty.call(this, member, match[1], match[2]); } else if (eventRegex.test(member.name)) { const match = member.name.match(eventRegex); handleEvent.call(this, member, match[1]); } } flushClassIfNeeded.call(this); } function handleMethod(member, className, methodName, parameters) { if (!currentClassName || !className || !methodName || className.toLowerCase() !== currentClassName.toLowerCase()) { this.errors.push(`Failed to process header as method: ${member.name}`); return; } parameters = parameters.trim().replace(/[\[\]]/g, ''); if (parameters !== member.args.map(arg => arg.name).join(', ')) this.errors.push(`Heading arguments for "${member.name}" do not match described ones, i.e. "${parameters}" != "${member.args.map(a => a.name).join(', ')}"`); const args = member.args.map(createPropertyFromJSON); let returnType = null; let returnComment = ''; if (member.returnType) { const returnProperty = createPropertyFromJSON(member.returnType); returnType = returnProperty.type; returnComment = returnProperty.comment; } const method = Documentation.Member.createMethod(methodName, args, returnType, returnComment, member.comment); currentClassMembers.push(method); } function createPropertyFromJSON(payload) { const type = new Documentation.Type(payload.type, payload.properties.map(createPropertyFromJSON)); const required = payload.required; return Documentation.Member.createProperty(payload.name, type, payload.comment, required); } function handleProperty(member, className, propertyName) { if (!currentClassName || !className || !propertyName || className.toLowerCase() !== currentClassName.toLowerCase()) { this.errors.push(`Failed to process header as property: ${member.name}`); return; } const type = member.returnType ? member.returnType.type : null; const properties = member.returnType ? member.returnType.properties : []; currentClassMembers.push(createPropertyFromJSON({type, name: propertyName, properties, comment: member.comment})); } function handleEvent(member, eventName) { if (!currentClassName || !eventName) { this.errors.push(`Failed to process header as event: ${member.name}`); return; } currentClassMembers.push(Documentation.Member.createEvent(eventName, member.returnType && createPropertyFromJSON(member.returnType).type, member.comment)); } function flushClassIfNeeded() { if (currentClassName === null) return; this.classes.push(new Documentation.Class(currentClassName, currentClassMembers, currentClassExtends, currentClassComment)); currentClassName = null; currentClassMembers = []; } } } /** * @param {!Page} page * @param {!Array} sources * @return {!Promise<{documentation: !Documentation, errors: !Array}>} */ module.exports = async function(page, sources) { const classes = []; const errors = []; for (const source of sources) { const outline = await MDOutline.create(page, source.text()); classes.push(...outline.classes); errors.push(...outline.errors); } const documentation = new Documentation(classes); return { documentation, errors }; };