import { ChildProcess , spawn } from 'child_process' ; import chokidar from 'chokidar' ; import { createHash } from 'crypto' ; import { EventEmitter } from 'events' ; import FileType from 'file-type' ; import fs from 'fs-extra' ; import { resolve } from 'path' ; import { getTOCFiles } from '../theme/src/utils/jupyterBook' ; import { copyPublicFiles } from './copy-public-files' ; const ROOT_DIR = resolve ( __dirname , '..' ) ; const PORT = process . env . PORT || '8080' ; const BUILD_HASH_FILE = resolve ( ROOT_DIR , '_build/jupyter-build-hash' ) ; const BUILD_HASH_IGNORE_PATTERN = /\.DS_Store/ ; const MEDIA_FILE_PATTERN = /image|video/ ; function isErronoException ( value ) { return ! ! ( value as NodeJS . ErrnoException ) . errno ; } /**
 * Events emitted by the dev server.
 */ enum Events { /**
     * Event for when the Jupyter Book build is complete.
     */ JupyterBuildComplete = 'jupyter-build-complete' } class DevServer extends EventEmitter { buildProcess ? : ChildProcess ; /**
     * Runs `jupyter-book` to build the napari docs as a subprocess. Running
     * processes are cancelled to ensure the build is up-to-date.
     */ private async buildDocs ( ) { if ( this . buildProcess ) { console . log ( 'Cancelling existing build' ) ; this . buildProcess . kill ( ) ; } const buildHash = await this . createBuildHash ( ) ; const prevBuildHash = await this . getBuildHash ( ) ; if ( buildHash === prevBuildHash ) { console . log ( 'Skipping Jupyter Book build' ) ; console . log ( 'Hash:' , buildHash ) ; this . emit ( Events . JupyterBuildComplete ) ; return ; } console . log ( 'Building Jupyter Book' ) ; console . log ( 'Hash:' , buildHash ) ; this . buildProcess = spawn ( 'jupyter-book' , [ 'build' , ROOT_DIR ] , { stdio : 'inherit' , } ) ; this . buildProcess . on ( 'error' , ( error ) => { if ( isErronoException ( error ) && error . code === 'ENOENT' ) { console . error ( 'Unable to find `jupyter-book`. Is it installed?' ) ; process . exit ( - 1 ) ; } console . error ( 'Error running Jupyter Book:' , error ) ; } ) ; this . buildProcess . once ( 'exit' , ( ) => { this . writeBuildHash ( buildHash ) . catch ( ( err ) => console . error ( 'Unable to write build hash:' , err ) ) ; this . buildProcess = undefined ; this . emit ( Events . JupyterBuildComplete ) ; } ) ; } returns private async createBuildHash ( ) { const fileStack = await this . getWatchFiles ( ) ; const files = [ ] ; const hash = createHash ( 'sha256' ) ; while ( fileStack . length > 0 ) { const file = fileStack . pop ( ) as string ; if ( await fs . pathExists ( file ) ) { const stats = await fs . stat ( file ) ; if ( stats . isDirectory ( ) ) { const nextFiles = await fs . readdir ( file ) ; fileStack . push ( ... nextFiles . map ( ( nextFile ) => resolve ( file , nextFile ) ) ) ; } else if ( ! BUILD_HASH_IGNORE_PATTERN . exec ( file ) ) { files . push ( file ) ; } } } const hashValues = await Promise . all ( files . map ( async ( file ) => { const fileType = await FileType . fromFile ( file ) ; const mime = fileType ?. mime ?? '' ; if ( MEDIA_FILE_PATTERN . exec ( mime ) ) { const stats = await fs . stat ( file ) ; return ( stats . ctimeMs + stats . mtimeMs ) . toString ( ) ; } return fs . readFile ( file , 'utf-8' ) ; } ) ) ; hashValues . sort ( ) . forEach ( ( value ) => hash . update ( value ) ) ; return hash . digest ( 'base64' ) . toString ( ) ; } param hash private async writeBuildHash ( hash ) { return fs . writeFile ( BUILD_HASH_FILE , hash ) ; } returns private async getBuildHash ( ) { const exists = await fs . pathExists ( BUILD_HASH_FILE ) ; if ( ! exists ) { return '' ; } return fs . readFile ( BUILD_HASH_FILE , 'utf-8' ) ; } returns private async getWatchFiles ( ) { const files = await getTOCFiles ( ) ; const watchFiles = new Set ( [ 'assets' , '_toc.yml' , '_config.yml' , 'theme/napari' , 'theme/napari_code_theme.py' , ] ) ; for ( const file of files ) { if ( file === 'index' ) { watchFiles . add ( 'index.md' ) ; } else { const [ parentDir ] = file . split ( '/' ) ; if ( parentDir ) { watchFiles . add ( parentDir ) ; } } } return Array . from ( watchFiles ) ; } /**
     * Re-builds the napari docs on file changes.
     */ private async watchDocs ( ) { const watchFiles = await this . getWatchFiles ( ) ; const watcher = chokidar . watch ( watchFiles ) ; watcher . on ( 'change' , ( path ) => { console . log ( `${ path } changed, re-building docs` ) ; this . buildDocs ( ) . catch ( ( err ) => { console . error ( 'Unable to build docs on file changes:' , err ) ; process . exit ( - 1 ) ; } ) ; } ) ; await this . buildDocs ( ) ; } private startNextDevServer ( ) { const nextCli = resolve ( ROOT_DIR , 'node_modules/.bin/next' ) ; spawn ( nextCli , [ '-p' , PORT ] , { stdio : 'inherit' , } ) ; } async start ( ) { this . once ( Events . JupyterBuildComplete , ( ) => { this . startNextDevServer ( ) ; } ) ; this . on ( Events . JupyterBuildComplete , ( ) => { copyPublicFiles ( ) . catch ( ( err ) => console . error ( 'Unable to copy public files:' , err ) ) ; } ) ; await this . watchDocs ( ) ; } } const server = new DevServer ( ) ; server . start ( ) . catch ( ( err ) => console . error ( 'Unable to start dev server!' , err ) ) ;