import fs from 'fs' ; import { readdir , readFile , writeFile } from 'fs/promises' ; import beautify from 'js-beautify' ; import minimatch from 'minimatch' ; import { HTMLElement , parse , parse as parseHtml } from 'node-html-parser' ; import nunjucks from 'nunjucks' ; import path from 'path' ; import pug from 'pug' ; import { asyncFilter , asyncFind , asyncSome , capitalize , isHtmlPathname , isScriptPathname } from '../helpers' ; import { isDefined } from '../helpers/ts-extras' ; import { Library , p5Version } from './Library' ; import { Script } from './Script' ; const templateDir = path . join ( __dirname , './templates' ) ; const defaultGenerationOptions = { draw : true , examples : true } ; const defaultDirectoryExclusions = [ '.*' , '*~' , '*.log' , 'node_modules' , 'package.json' , 'package-lock.json' , '~*' , 'Icon\r' , 'Thumbs.db' ] ; export type SketchStructureType = 'html' | 'script' ; /** Sketch represents a p5.js Sketch. Is an interface to generate sketch files,
 *  find associated files, infer libraries, and scan directories for sketches that
 * they contain.
 *
 * A sketch can be an HTML sketch, or a script sketch.
 */ export abstract class Sketch { /** The directory that contains the sketch files. Other xxxFile properties are relative to this. */ public readonly dir : string ; /** For an HTML sketch, the pathname of the HTML file, relative to dir. */ abstract readonly htmlFile : string | null ; /** The main script file, relative to dir. */ public readonly scriptFile : string ; public readonly description ? : string ; protected readonly _title ? : string ; protected _name ? : string ; protected constructor ( dir , scriptFile = 'sketch.js' , options = { } ) { this . dir = dir ; this . scriptFile = scriptFile ; this . _title = options . title ; this . description = options . description ; } category static create ( mainFile , options = { } ) { if ( isHtmlPathname ( mainFile ) ) { return new HtmlSketch ( path . dirname ( mainFile ) , path . basename ( mainFile ) , options . scriptFile , options ) ; } else if ( isScriptPathname ( mainFile ) ) { if ( mainFile && options . scriptFile ) { throw new Error ( `Cannot specify both a JavaScript mainFile and options.scriptFile` ) ; } return new ScriptSketch ( path . dirname ( mainFile ) , path . basename ( mainFile ) , options ) ; } else { throw new Error ( `Unsupported file type: ${ mainFile }` ) ; } } category static async fromHtmlFile ( htmlFile ) { return HtmlSketch . fromFile ( htmlFile ) ; } category static async fromScriptFile ( scriptFile ) { return ScriptSketch . fromFile ( scriptFile ) ; } category static async fromDirectory ( dir , options ? ) { const sketch = await Sketch . isSketchDir ( dir , options ) ; if ( ! sketch ) throw new Error ( `Directory ${ dir } is not a sketch directory` ) ; return sketch ; } category static fromFile ( filePath ) { if ( fs . statSync ( filePath ) . isDirectory ( ) ) { return Sketch . fromDirectory ( filePath ) ; } else if ( isScriptPathname ( filePath ) ) { return Sketch . fromScriptFile ( filePath ) ; } else if ( isHtmlPathname ( filePath ) ) { return Sketch . fromHtmlFile ( filePath ) ; } else { throw new Error ( `Unrecognized file type: ${ filePath }` ) ; } } category static async analyzeDirectory ( dir , options ? ) { const sketches = [ ] ; const exclusions = options ?. exclusions || defaultDirectoryExclusions ; let files = ( await readdir ( dir ) ) . filter ( file => ! exclusions . some ( pattern => minimatch ( file , pattern ) ) ) ; files = await asyncFilter ( files , async ( name ) => { const dirPath = path . join ( dir , name ) ; const sketch = await Sketch . isSketchDir ( dirPath , { exclusions } ) ; if ( sketch ) { sketch . name = name ; sketches . push ( sketch ) ; } return ! sketch ; } ) ; for ( const file of files ) { const filePath = path . join ( dir , file ) ; if ( await Sketch . isSketchHtmlFile ( filePath ) ) { sketches . push ( await Sketch . fromHtmlFile ( filePath ) ) ; } } for ( const file of removeProjectFiles ( files ) ) { const filePath = path . join ( dir , file ) ; if ( await Sketch . isSketchScriptFile ( filePath ) ) { sketches . push ( await Sketch . fromScriptFile ( filePath ) ) ; } } return { sketches , allFiles : files , unassociatedFiles : removeProjectFiles ( files ) } ; function removeProjectFiles ( files ) { return files . filter ( f => ! sketches . some ( s => s . files . includes ( f ) ) ) ; } } category static async isSketchHtmlFile ( htmlFile ) { return HtmlSketch . isSketchHtmlFile ( htmlFile ) ; } category static async isSketchScriptFile ( file ) { return ScriptSketch . isSketchScriptFile ( file ) ; } category static async isSketchFile ( file ) { return ( ( await Sketch . isSketchHtmlFile ( file ) ) || ( await Sketch . isSketchScriptFile ( file ) ) ) ; } category static async isSketchDir ( dir , { exclusions = defaultDirectoryExclusions , depth = 10 } = { } ) { if ( ! fs . existsSync ( dir ) || ! fs . statSync ( dir ) . isDirectory ( ) ) { return null ; } const files = await fs . readdirSync ( dir ) . filter ( file => ! exclusions . some ( pattern => minimatch ( file , pattern ) ) ) . map ( file => path . join ( dir , file ) ) ; const indexFiles = await asyncFilter ( files . filter ( file => /^index\.html?$/i . test ( path . basename ( file ) ) ) , Sketch . isSketchHtmlFile ) ; if ( indexFiles . length > 1 ) { return null ; } const [ indexFile ] = indexFiles ; const otherHtmlSketches = await asyncFilter ( files . filter ( file => file !== indexFile ) , Sketch . isSketchHtmlFile ) ; if ( otherHtmlSketches . length > 0 ) { return null ; } const sketch = indexFile ? await Sketch . fromHtmlFile ( indexFile ) : null ; const associatedScripts = new Set ( sketch ? sketch . files . map ( file => path . join ( sketch . dir , file ) ) : [ ] ) ; const scriptSketches = await asyncFilter ( files . filter ( file => ! associatedScripts . has ( file ) ) , Sketch . isSketchScriptFile ) ; if ( indexFiles . length + scriptSketches . length !== 1 ) { return null ; } if ( await subdirectoriesContainSketchFiles ( dir , depth ) ) { return null ; } return sketch || ( await Sketch . fromFile ( scriptSketches [ 0 ] ) ) ; async function subdirectoriesContainSketchFiles ( dir , depth , includeFiles = false ) { if ( depth <= 0 ) { return false ; } const files = fs . readdirSync ( dir ) . filter ( file => ! exclusions . some ( pattern => minimatch ( file , pattern ) ) ) . map ( file => path . join ( dir , file ) ) ; return asyncSome ( files , file => fs . statSync ( file ) . isDirectory ( ) ? subdirectoriesContainSketchFiles ( file , depth - 1 ) : includeFiles ? Sketch . isSketchFile ( file ) : Promise . resolve ( false ) ) ; } } /** The file structure of the sketch. */ abstract get structureType ( ) : SketchStructureType ; /** For an HTML sketch, this is the HTML file. For a JavaScript sketch, this is
     * the JavaScript file. In either case, it is relative to dir.
     */ abstract get mainFile ( ) : string ; get mainFilePath ( ) : string { return path . join ( this . dir , this . mainFile ) ; } get htmlFilePath ( ) : string | null { return this . htmlFile ? path . join ( this . dir , this . htmlFile ) : null ; } get scriptFilePath ( ) : string { return path . join ( this . dir , this . scriptFile ) ; } get name ( ) : string { return ( this . _name || this . mainFile . replace ( /\.(html?|js)$/i , '' ) . replace ( /\s*[-_]\s*/g , ' ' ) ) ; } set name ( value ) { this . _name = value ; } /** For an HTML sketch, this is the <title> element. Otherwise it is the base
     * name of the main file.
     */ get title ( ) : string { if ( this . _title ) { return this . _title ; } const title = this . getTitleFromFile ( ) ; if ( title ) { return title ; } const basename = path . basename ( this . mainFile ) ; return capitalize ( basename . replace ( /\.(html?|js)$/i , '' ) ) . replace ( /\s*[-_]\s*/g , ' ' ) ; } protected getTitleFromFile ( ) { return null ; } /** The HTML file (for an HTML sketch); any JavaScript files; any files that
     * the HTML file includes; and any files that the JavaScript files include,
     * to the extent that this can be determined by static inspection.
     *
     * File names are relative to sketch.dirPath.
     */ abstract get files ( ) : readonly string [ ] ; category get libraries ( ) : readonly Library [ ] { return this . impliedLibraries ( ) ; } protected impliedLibraries ( ) { return Library . inferFromScripts ( this . files . filter ( name => isScriptPathname ( name ) ) . map ( name => path . join ( this . dir , name ) ) ) ; } protected static readonly indexTemplateName = 'index.pug' ; category async generate ( force = false , options = { } ) { const files = new Map < string , string > ( ) ; if ( this . htmlFile ) files . set ( this . htmlFile , Sketch . indexTemplateName ) ; files . set ( this . scriptFile , 'sketch.js.njk' ) ; if ( ! force ) { [ ... files . keys ( ) ] . filter ( fs . existsSync ) . forEach ( filename => { writeFile ( filename , '' ) ; fs . unlinkSync ( filename ) ; } ) ; } for ( const [ filename , templateName ] of files ) { await this . writeGeneratedFile ( templateName , filename , force , options ) ; } return [ ... files . keys ( ) ] ; } protected async writeGeneratedFile ( templateName , filename , force , templateOptions ) { const filepath = path . join ( this . dir , filename ) ; const content = await this . getGeneratedFileContent ( templateName , templateOptions ) ; await writeFile ( filepath , content , force ? { } : { flag : 'wx' } ) ; return filepath ; } private async getGeneratedFileContent ( base , options ) { const libraries = this . libraries ; const data = { title : this . title , libraries , p5Version , scriptFile : this . scriptFile , ... defaultGenerationOptions , ... options } ; const templatePath = path . join ( templateDir , base ) ; if ( templatePath . endsWith ( '.njk' ) ) { const template = nunjucks . compile ( await readFile ( templatePath , 'utf-8' ) ) ; return template . render ( data ) . trim ( ) + '\n' ; } if ( templatePath . endsWith ( '.pug' ) ) { const html = pug . renderFile ( templatePath , { pretty : false , ... data } ) . replace ( /(<!-- .*?\S)(-->)/g , '$1 $2' ) . replace ( /<!-- pug: newline\s*-->/g , '\n\n' ) ; return beautify . html ( html , { indent_size : 4 , end_with_newline : true } ) ; } throw new Error ( `Unknown template extension: ${ templatePath }` ) ; } public async getHtmlContent ( ) { return this . htmlFilePath ? await readFile ( this . htmlFilePath , 'utf-8' ) : this . getGeneratedFileContent ( Sketch . indexTemplateName , { } ) ; } category public abstract convert ( options ) ; } export class HtmlSketch extends Sketch { public readonly htmlFile : string ; constructor ( dir , htmlFile = 'index.html' , scriptFile = 'sketch.js' , options = { } ) { super ( dir , scriptFile , options ) ; this . htmlFile = htmlFile ; } static async fromFile ( htmlFilePath ) { const dir = path . dirname ( htmlFilePath ) ; const htmlContent = await readFile ( htmlFilePath , 'utf-8' ) ; const htmlRoot = parseHtml ( htmlContent ) ; const description = htmlRoot . querySelector ( 'head meta[name=description]' ) ?. attributes . content . trim ( ) ; const scripts = this . getLocalScriptFiles ( htmlRoot ) ; const scriptFile = ( await asyncFind ( scripts , name => Sketch . isSketchScriptFile ( path . join ( dir , name ) ) ) ) || scripts [ 0 ] ; return new HtmlSketch ( dir , path . basename ( htmlFilePath ) , scriptFile , { description } ) ; } static async isSketchHtmlFile ( htmlFilePath ) { if ( ! isHtmlPathname ( htmlFilePath ) || ! fs . existsSync ( htmlFilePath ) || fs . statSync ( htmlFilePath ) . isDirectory ( ) ) { return false ; } const html = await readFile ( htmlFilePath , 'utf-8' ) ; const htmlRoot = parseHtml ( html ) ; const scriptSrcs = htmlRoot . querySelectorAll ( 'script[src]' ) . map ( node => node . attributes . src ) ; return scriptSrcs . some ( src => src . search ( /\bp5(\.min)?\.js$/ ) ) ; } get structureType ( ) : SketchStructureType { return 'html' ; } get mainFile ( ) : string { return this . htmlFile ; } get files ( ) : readonly string [ ] { const files = [ this . htmlFile , this . scriptFile , ... this . getAssociatedFiles ( ) ] ; return [ ... new Set ( files ) ] ; } get libraries ( ) : Library [ ] { return this . explicitLibraries ( ) ; } private explicitLibraries ( ) { const htmlFilePath = this . htmlFilePath ! ; if ( ! fs . existsSync ( htmlFilePath ) ) return [ ] ; const content = fs . readFileSync ( htmlFilePath , 'utf-8' ) ; const htmlRoot = parse ( content ) ; const libs = htmlRoot . querySelectorAll ( 'script[src]' ) . map ( node => node . attributes . src ) . map ( importPath => Library . find ( { importPath } ) ) ; return libs . filter ( isDefined ) ; } protected getTitleFromFile ( ) { const filePath = this . htmlFilePath ! ; if ( fs . existsSync ( filePath ) ) { const htmlContent = fs . readFileSync ( filePath , 'utf-8' ) ; const htmlRoot = parseHtml ( htmlContent ) ; const title = htmlRoot . querySelector ( 'head title' ) ?. text ?. trim ( ) ; if ( title ) return title ; } return null ; } private getAssociatedFiles ( ) { const htmlFile = this . htmlFilePath ! ; if ( fs . existsSync ( htmlFile ) ) { const html = fs . readFileSync ( htmlFile , 'utf-8' ) ; const htmlRoot = parseHtml ( html ) ; const scriptFiles = this . getLocalScriptFiles ( htmlRoot ) ; return [ ... this . getLocalScriptFiles ( htmlRoot ) , ... htmlRoot . querySelectorAll ( 'head link[href]' ) . map ( e => e . attributes . href . replace ( /^\.\// , '' ) ) . filter ( s => ! s . match ( /https?:/ ) ) , ... scriptFiles . flatMap ( name => Script . getAssociatedFiles ( path . join ( this . dir , name ) ) ) ] ; } else { return [ ] ; } } protected getLocalScriptFiles ( htmlRoot ? ) { if ( ! htmlRoot ) { const html = fs . readFileSync ( this . htmlFilePath ! , 'utf-8' ) ; htmlRoot = parseHtml ( html ) ; } return HtmlSketch . getLocalScriptFiles ( htmlRoot ) ; } private static getLocalScriptFiles ( htmlRoot ) { return htmlRoot . querySelectorAll ( 'script[src]' ) . map ( e => e . attributes . src . replace ( /^\.\// , '' ) ) . filter ( s => ! s . match ( /https?:/ ) ) ; } public async convert ( options ) { switch ( options . type ) { case 'script' : { const htmlPath = this . htmlFilePath ! ; const html = await readFile ( htmlPath , 'utf-8' ) ; const htmlRoot = parseHtml ( html ) ; const scriptSrcs = htmlRoot . querySelectorAll ( 'script' ) . map ( e => e . attributes . src ) ; if ( scriptSrcs . some ( s => ! s ) ) { throw new Error ( `${ htmlPath } contains an inline script` ) ; } const localScripts = scriptSrcs . filter ( s => ! /^https?:/ . test ( s ) ) ; switch ( localScripts . length ) { case 0 : throw new Error ( `${ htmlPath } does not contain any local scripts` ) ; case 1 : if ( ! isScriptPathname ( localScripts [ 0 ] ) ) { throw new Error ( `${ htmlPath } refers to a script file with the wrong extension` ) ; } if ( ! fs . existsSync ( path . join ( this . dir , localScripts [ 0 ] ) ) ) { throw new Error ( `${ htmlPath } refers to a script file that does not exist` ) ; } break ; default : if ( localScripts . length > 1 ) { throw new Error ( `${ htmlPath } contains multiple script tags` ) ; } } const htmlLibs = this . explicitLibraries ( ) ; const scriptLibs = this . impliedLibraries ( ) ; const htmlNotScript = htmlLibs . filter ( lib => ! scriptLibs . some ( s => s . name === lib . name ) ) ; const scriptNotHtml = scriptLibs . filter ( lib => ! htmlLibs . some ( h => h . name === lib . name ) ) ; if ( htmlNotScript . length ) { throw new Error ( `${ this . htmlFile } contains libraries that are not implied by ${ this . scriptFile }: ${ htmlLibs . map ( lib => lib . name ) }` ) ; } if ( scriptNotHtml . length ) { throw new Error ( `${ this . scriptFile } implies libraries that are not in ${ this . htmlFile }: ${ scriptNotHtml . map ( lib => lib . name ) }` ) ; } fs . unlinkSync ( htmlPath ) ; } } } } export class ScriptSketch extends Sketch { static async fromFile ( scriptFile ) { const dir = path . dirname ( scriptFile ) ; let description ; if ( fs . existsSync ( scriptFile ) ) { const source = await readFile ( scriptFile , 'utf-8' ) ; description = this . getDescriptionFromScript ( source ) ; } return new ScriptSketch ( dir , path . basename ( scriptFile ) , { description } ) ; } static async isSketchScriptFile ( file ) { if ( ! isScriptPathname ( file ) || ! fs . existsSync ( file ) || fs . statSync ( file ) . isDirectory ( ) ) { return false ; } try { const { defs , refs } = Script . fromFile ( file ) ; return defs . get ( 'setup' ) === 'function' && refs . has ( 'createCanvas' ) ; } catch ( e ) { if ( e instanceof SyntaxError ) { const source = await readFile ( file , 'utf-8' ) ; return ( /function\s+(setup)\b/ . test ( source ) && /\bcreateCanvas\s*\(/ . test ( source ) ) ; } throw e ; } } get structureType ( ) : SketchStructureType { return 'script' ; } get mainFile ( ) : string { return this . scriptFile ; } get htmlFile ( ) : null { return null ; } get files ( ) : readonly string [ ] { const files = [ this . scriptFile , ... Script . getAssociatedFiles ( path . join ( this . dir , this . scriptFile ) ) ] ; return [ ... new Set ( files ) ] ; } public async convert ( options ) { switch ( options . type ) { case 'html' : { const htmlName = this . mainFile . replace ( /\.js$/ , '' ) + '.html' ; const htmlPath = path . join ( this . dir , htmlName ) ; if ( fs . existsSync ( htmlPath ) ) { throw new Error ( `${ htmlPath } already exists` ) ; } await this . writeGeneratedFile ( Sketch . indexTemplateName , htmlName , false , { } ) ; } } } private static getDescriptionFromScript ( content ) { let text ; let m = content . match ( /\n*((?:\/\/.*\n)+)/ ) ; if ( m ) { text = m [ 1 ] . replace ( /^\/\//gm , '' ) . trim ( ) ; } m = content . match ( /\n*\/\*+(.+?)\*\//s ) ; if ( m ) { text = m [ 1 ] . replace ( /^\s*\**/gm , '' ) . trim ( ) ; } m = text ?. match ( /^Description:\s*(.+)/s ) || null ; if ( m ) { return m [ 1 ] . replace ( /\n\n.+/ , '' ) ; } return undefined ; } }