/** * 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); // Extract headings. await page.setContent(html); const {classes, errors} = await page.evaluate(() => { const classes = []; let currentClass = {}; let member = {}; const errors = []; for (const element of document.body.querySelectorAll('h3, h4, h4 + ul > li')) { if (element.matches('h3')) { currentClass = { name: element.textContent, members: [], }; classes.push(currentClass); } else if (element.matches('h4')) { member = { name: element.textContent, args: [], returnType: null }; currentClass.members.push(member); } else if (element.matches('li') && element.firstChild.matches && element.firstChild.matches('code')) { member.args.push(parseProperty(element)); } else if (element.matches('li') && element.firstChild.nodeType === Element.TEXT_NODE && element.firstChild.textContent.toLowerCase().startsWith('return')) { member.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(`${member.name} has mistyped 'return' type declaration: expected exactly '${expectedText}', found '${actualText}'.`); } } return {classes, errors}; function parseProperty(element) { const str = element.textContent; const name = str.substring(0, str.indexOf('<')).trim(); const type = findType(str); const properties = []; // Strings have enum values instead of properties if (!type.includes('string')) { for (const childElement of element.querySelectorAll(':scope > ul > li')) properties.push(parseProperty(childElement)); } return { name, type, 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'; } }); 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 = []; for (const cls of classes) { const match = cls.name.match(classHeading); if (!match) continue; currentClassName = match[1]; 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; if (member.returnType) returnType = createPropertyFromJSON(member.returnType).type; const method = Documentation.Member.createMethod(methodName, args, returnType); currentClassMembers.push(method); } function createPropertyFromJSON(payload) { const type = new Documentation.Type(payload.type, payload.properties.map(createPropertyFromJSON)); return Documentation.Member.createProperty(payload.name, type); } 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; } currentClassMembers.push(Documentation.Member.createProperty(propertyName)); } 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)); } function flushClassIfNeeded() { if (currentClassName === null) return; this.classes.push(new Documentation.Class(currentClassName, currentClassMembers)); 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 }; };