2017-07-28 08:09:26 +00:00
/ * *
* 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 .
* /
2017-07-11 15:57:26 +00:00
const Documentation = require ( './Documentation' ) ;
const commonmark = require ( 'commonmark' ) ;
class MDOutline {
/ * *
2017-07-12 18:42:36 +00:00
* @ param { ! Page } page
2017-07-11 15:57:26 +00:00
* @ param { string } text
* @ return { ! MDOutline }
* /
2017-07-12 18:42:36 +00:00
static async create ( page , text ) {
2017-07-11 15:57:26 +00:00
// Render markdown as HTML.
const reader = new commonmark . Parser ( ) ;
const parsed = reader . parse ( text ) ;
const writer = new commonmark . HtmlRenderer ( ) ;
const html = writer . render ( parsed ) ;
2019-01-28 23:12:45 +00:00
page . on ( 'console' , msg => {
console . log ( msg . text ( ) ) ;
} ) ;
2017-07-11 15:57:26 +00:00
// Extract headings.
await page . setContent ( html ) ;
2017-07-15 17:20:10 +00:00
const { classes , errors } = await page . evaluate ( ( ) => {
2017-08-21 23:39:04 +00:00
const classes = [ ] ;
const errors = [ ] ;
2019-01-28 23:12:45 +00:00
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 ) ) ;
2017-07-11 15:57:26 +00:00
}
2017-07-15 17:20:10 +00:00
return { classes , errors } ;
2018-11-21 22:49:08 +00:00
2019-01-28 23:12:45 +00:00
/ * *
* @ param { HTMLLIElement } element
* /
2018-11-21 22:49:08 +00:00
function parseProperty ( element ) {
2019-01-28 23:12:45 +00:00
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 ( ) ;
2018-11-21 22:49:08 +00:00
const type = findType ( str ) ;
const properties = [ ] ;
2019-01-28 23:12:45 +00:00
const comment = str . substring ( str . indexOf ( '<' ) + type . length + 2 ) . trim ( ) ;
2018-11-21 22:49:08 +00:00
// Strings have enum values instead of properties
if ( ! type . includes ( 'string' ) ) {
2019-01-28 23:12:45 +00:00
for ( const childElement of element . querySelectorAll ( ':scope > ul > li' ) ) {
const property = parseProperty ( childElement ) ;
property . required = property . comment . includes ( '***required***' ) ;
properties . push ( property ) ;
}
2018-11-21 22:49:08 +00:00
}
return {
name ,
type ,
2019-01-28 23:12:45 +00:00
comment ,
2018-11-21 22:49:08 +00:00
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' ;
}
2019-01-28 23:12:45 +00:00
/ * *
* @ 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 ;
}
2017-07-11 15:57:26 +00:00
} ) ;
2017-07-15 17:20:10 +00:00
return new MDOutline ( classes , errors ) ;
2017-07-11 15:57:26 +00:00
}
2017-07-15 17:20:10 +00:00
constructor ( classes , errors ) {
2017-07-11 15:57:26 +00:00
this . classes = [ ] ;
2017-07-15 17:20:10 +00:00
this . errors = errors ;
2017-07-11 15:57:26 +00:00
const classHeading = /^class: (\w+)$/ ;
const constructorRegex = /^new (\w+)\((.*)\)$/ ;
2017-07-18 01:56:56 +00:00
const methodRegex = /^(\w+)\.([\w$]+)\((.*)\)$/ ;
2017-07-12 15:01:21 +00:00
const propertyRegex = /^(\w+)\.(\w+)$/ ;
2017-07-14 20:03:21 +00:00
const eventRegex = /^event: '(\w+)'$/ ;
2017-07-11 15:57:26 +00:00
let currentClassName = null ;
2017-07-14 05:52:02 +00:00
let currentClassMembers = [ ] ;
2019-01-28 23:12:45 +00:00
let currentClassComment = '' ;
let currentClassExtends = null ;
2017-07-11 15:57:26 +00:00
for ( const cls of classes ) {
2017-08-21 23:39:04 +00:00
const match = cls . name . match ( classHeading ) ;
2017-07-12 18:42:36 +00:00
if ( ! match )
continue ;
2017-07-11 15:57:26 +00:00
currentClassName = match [ 1 ] ;
2019-01-28 23:12:45 +00:00
currentClassComment = cls . comment ;
currentClassExtends = cls . extendsName ;
2017-08-21 23:39:04 +00:00
for ( const member of cls . members ) {
2017-07-12 15:01:21 +00:00
if ( constructorRegex . test ( member . name ) ) {
2017-08-21 23:39:04 +00:00
const match = member . name . match ( constructorRegex ) ;
2017-07-12 15:01:21 +00:00
handleMethod . call ( this , member , match [ 1 ] , 'constructor' , match [ 2 ] ) ;
} else if ( methodRegex . test ( member . name ) ) {
2017-08-21 23:39:04 +00:00
const match = member . name . match ( methodRegex ) ;
2017-07-12 15:01:21 +00:00
handleMethod . call ( this , member , match [ 1 ] , match [ 2 ] , match [ 3 ] ) ;
} else if ( propertyRegex . test ( member . name ) ) {
2017-08-21 23:39:04 +00:00
const match = member . name . match ( propertyRegex ) ;
2017-07-12 15:01:21 +00:00
handleProperty . call ( this , member , match [ 1 ] , match [ 2 ] ) ;
2017-07-14 20:03:21 +00:00
} else if ( eventRegex . test ( member . name ) ) {
2017-08-21 23:39:04 +00:00
const match = member . name . match ( eventRegex ) ;
2017-07-14 20:03:21 +00:00
handleEvent . call ( this , member , match [ 1 ] ) ;
2017-07-11 15:57:26 +00:00
}
}
flushClassIfNeeded . call ( this ) ;
}
2017-07-12 15:01:21 +00:00
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 , '' ) ;
2018-11-21 22:49:08 +00:00
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 ;
2019-01-28 23:12:45 +00:00
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 ) ;
2017-07-14 05:52:02 +00:00
currentClassMembers . push ( method ) ;
2017-07-12 15:01:21 +00:00
}
2018-11-21 22:49:08 +00:00
function createPropertyFromJSON ( payload ) {
const type = new Documentation . Type ( payload . type , payload . properties . map ( createPropertyFromJSON ) ) ;
2019-01-28 23:12:45 +00:00
const required = payload . required ;
return Documentation . Member . createProperty ( payload . name , type , payload . comment , required ) ;
2018-11-21 22:49:08 +00:00
}
2017-07-12 15:01:21 +00:00
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 ;
}
2019-01-28 23:12:45 +00:00
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 } ) ) ;
2017-07-12 15:01:21 +00:00
}
2017-07-14 20:03:21 +00:00
function handleEvent ( member , eventName ) {
if ( ! currentClassName || ! eventName ) {
this . errors . push ( ` Failed to process header as event: ${ member . name } ` ) ;
return ;
}
2019-01-28 23:12:45 +00:00
currentClassMembers . push ( Documentation . Member . createEvent ( eventName , member . returnType && createPropertyFromJSON ( member . returnType ) . type , member . comment ) ) ;
2017-07-14 20:03:21 +00:00
}
2017-07-11 15:57:26 +00:00
function flushClassIfNeeded ( ) {
if ( currentClassName === null )
return ;
2019-01-28 23:12:45 +00:00
this . classes . push ( new Documentation . Class ( currentClassName , currentClassMembers , currentClassExtends , currentClassComment ) ) ;
2017-07-11 15:57:26 +00:00
currentClassName = null ;
2017-07-14 05:52:02 +00:00
currentClassMembers = [ ] ;
2017-07-11 15:57:26 +00:00
}
}
}
/ * *
2017-07-12 18:42:36 +00:00
* @ param { ! Page } page
2017-07-31 04:49:04 +00:00
* @ param { ! Array < ! Source > } sources
2017-07-11 15:57:26 +00:00
* @ return { ! Promise < { documentation : ! Documentation , errors : ! Array < string > } > }
* /
2017-07-31 04:49:04 +00:00
module . exports = async function ( page , sources ) {
2017-08-21 23:39:04 +00:00
const classes = [ ] ;
const errors = [ ] ;
for ( const source of sources ) {
const outline = await MDOutline . create ( page , source . text ( ) ) ;
2017-07-11 15:57:26 +00:00
classes . push ( ... outline . classes ) ;
errors . push ( ... outline . errors ) ;
}
const documentation = new Documentation ( classes ) ;
return { documentation , errors } ;
} ;