puppeteer/utils/doclint/check_public_api/MDBuilder.js
2022-06-14 13:55:35 +02:00

418 lines
13 KiB
JavaScript

/**
* 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.js');
const { Parser, HtmlRenderer } = require('commonmark');
class MDOutline {
/**
* @param {!Page} page
* @param {string} text
* @returns {!MDOutline}
*/
static async create(page, text) {
// Render markdown as HTML.
const reader = new Parser();
const parsed = reader.parse(text);
const writer = new 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
* @returns {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
* @returns {!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<!Source>} sources
* @returns {!Promise<{documentation: !Documentation, errors: !Array<string>}>}
*/
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 };
};