8ff9d598bf
Signed-off-by: Randolf Jung <jrandolf@chromium.org> Co-authored-by: Randolf Jung <jrandolf@chromium.org>
403 lines
13 KiB
JavaScript
403 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 };
|
|
};
|