Skip to content

Commit

Permalink
Merge pull request #20 from marp-team/auto-reloading
Browse files Browse the repository at this point in the history
Support HTML auto reloading on watch mode
  • Loading branch information
yhatt authored Sep 18, 2018
2 parents a9b74d4 + c565dd0 commit c02fe2b
Show file tree
Hide file tree
Showing 17 changed files with 422 additions and 23 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- Add `--watch` (`-w`) option to support watch mode ([#18](https://github.com/marp-team/marp-cli/pull/18))
- Support HTML auto reloading on watch mode ([#20](https://github.com/marp-team/marp-cli/pull/20))

### Fixed

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ npx @marp-team/marp-cli slide-deck.md -o output.html
# Convert slide deck into PDF
npx @marp-team/marp-cli slide-deck.md --pdf
npx @marp-team/marp-cli slide-deck.md -o output.pdf

# Watch mode
npx @marp-team/marp-cli -w slide-deck.md
```

> :information_source: You have to install [Google Chrome](https://www.google.com/chrome/) (or [Chromium](https://www.chromium.org/)) to convert slide deck into PDF.
Expand All @@ -39,6 +42,9 @@ docker run --rm -v $PWD:/home/marp/app/ marpteam/marp-cli slide-deck.md

# Convert slide deck into PDF by using Chromium in Docker
docker run --rm -v $PWD:/home/marp/app/ marpteam/marp-cli slide-deck.md --pdf

# Watch mode
docker run --rm --init -v $PWD:/home/marp/app/ -p 52000:52000 marpteam/marp-cli -w slide-deck.md
```

## Install
Expand Down Expand Up @@ -75,7 +81,7 @@ Under construction.
- [x] Select theme by option
- [x] Support configuration file (like `.marprc`)
- [x] Watch mode
- [ ] Auto-reload
- [x] Auto-reload
- [ ] Server mode
- [x] HTML templates
- [x] Template that has ready to actual presentation powered by [Bespoke](https://github.com/bespokejs/bespoke)
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@types/node": "^10.9.4",
"@types/pug": "^2.0.4",
"@types/puppeteer": "^1.6.4",
"@types/ws": "^6.0.1",
"@types/yargs": "^11.1.1",
"autoprefixer": "^9.1.5",
"bespoke": "^1.1.0",
Expand Down Expand Up @@ -100,8 +101,10 @@
"mkdirp": "^0.5.1",
"os-locale": "^3.0.1",
"pkg-up": "^2.0.0",
"portfinder": "^1.0.17",
"puppeteer-core": "^1.8.0",
"tmp": "^0.0.33",
"ws": "^6.0.0",
"yargs": "^12.0.2"
}
}
7 changes: 7 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { dependencies } from './package.json'

const external = [
...Object.keys(dependencies),
'crypto',
'fs',
'path',
'chrome-launcher/dist/chrome-finder',
Expand Down Expand Up @@ -41,6 +42,12 @@ export default [
input: 'src/templates/bespoke.js',
output: { file: 'lib/bespoke.js', format: 'iife' },
},
{
external,
plugins,
input: 'src/templates/watch.js',
output: { file: 'lib/watch.js', format: 'iife' },
},
{
external,
plugins,
Expand Down
6 changes: 6 additions & 0 deletions src/__mocks__/watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const watcher = require.requireActual('../watcher')

watcher.notifier.start = jest.fn()
watcher.notifier.sendTo = jest.fn()

export = watcher
8 changes: 8 additions & 0 deletions src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Engine } from './engine'
import { error } from './error'
import { File, FileType } from './file'
import templates, { TemplateResult } from './templates/'
import { notifier } from './watcher'

export enum ConvertType {
html = 'html',
Expand Down Expand Up @@ -65,6 +66,13 @@ export class Converter {
base,
lang,
readyScript,
notifyWS:
this.options.watch &&
file &&
file.type === FileType.File &&
type === ConvertType.html
? await notifier.register(file.absolutePath)
: undefined,
renderer: tplOpts =>
this.generateEngine(tplOpts).render(`${markdown}${additionals}`),
})
Expand Down
2 changes: 2 additions & 0 deletions src/templates/bare/bare.pug
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ html(lang=lang)
body
!= html
!= readyScript
if watchJs
script!= watchJs
6 changes: 4 additions & 2 deletions src/templates/bespoke/bespoke.pug
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ html(lang=lang)
style!= bespoke.css
style!= css
body
if progress
if bespoke.progress
.bespoke-progress-parent
.bespoke-progress-bar

!= html
!= readyScript
script(defer)!= bespoke.js
script!= bespoke.js
if watchJs
script!= watchJs
18 changes: 14 additions & 4 deletions src/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import bespokeScss from './bespoke/bespoke.scss'
export interface TemplateOptions {
base?: string
lang: string
notifyWS?: string
readyScript?: string
renderer: (tplOpts: MarpitOptions) => MarpitRenderResult
[prop: string]: any
Expand All @@ -34,6 +35,7 @@ export const bare: Template = async opts => {
...opts,
...rendered,
bare: { css: bareScss },
watchJs: await watchJs(opts.notifyWS),
}),
}
}
Expand All @@ -50,24 +52,32 @@ export const bespoke: Template = async opts => {
result: bespokePug({
...opts,
...rendered,
progress: false,
bespoke: {
css: bespokeScss,
js: await bespokeJs(),
js: await libJs('bespoke.js'),
progress: false,
},
watchJs: await watchJs(opts.notifyWS),
}),
}
}

export function bespokeJs() {
export function libJs(fn: string) {
return new Promise<string>((resolve, reject) =>
fs.readFile(
path.resolve(__dirname, './bespoke.js'), // __dirname is "lib" after bundle
path.resolve(__dirname, fn), // __dirname is "lib" after bundle
(e, data) => (e ? reject(e) : resolve(data.toString()))
)
)
}

export async function watchJs(notifyWS?: string) {
if (notifyWS === undefined) return false

const watchJs = await libJs('watch.js')
return `window.__marpCliWatchWS=${JSON.stringify(notifyWS)};${watchJs}`
}

const templates: { [name: string]: Template } = { bare, bespoke }

export default templates
3 changes: 3 additions & 0 deletions src/templates/watch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import watch from './watch/watch'

watch()
10 changes: 10 additions & 0 deletions src/templates/watch/watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default function() {
const wsPath = (<any>window).__marpCliWatchWS

if (wsPath) {
const ws = new WebSocket(wsPath)
ws.addEventListener('message', e => {
if (e.data === 'reload') location.reload()
})
}
}
82 changes: 80 additions & 2 deletions src/watcher.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import chalk from 'chalk'
import chokidar from 'chokidar'
import crypto from 'crypto'
import path from 'path'
import portfinder from 'portfinder'
import { Server as WSServer, ServerOptions } from 'ws'
import * as cli from './cli'
import { Converter, ConvertedCallback } from './converter'
import { File } from './file'
import { File, FileType } from './file'

export class Watcher {
chokidar: chokidar.FSWatcher
Expand All @@ -22,6 +25,8 @@ export class Watcher {
.on('change', f => this.convert(f))
.on('add', f => this.convert(f))

notifier.start()

cli.info(chalk.green('[Watch mode] Start watching...'))
}

Expand All @@ -31,7 +36,12 @@ export class Watcher {
)

try {
await this.converter.convertFiles(files, this.events.onConverted)
await this.converter.convertFiles(files, ret => {
this.events.onConverted(ret)

if (ret.file.type === FileType.File)
notifier.sendTo(ret.file.absolutePath, 'reload')
})
} catch (e) {
this.events.onError(e)
}
Expand All @@ -42,6 +52,74 @@ export class Watcher {
}
}

export class WatchNotifier {
listeners: Map<string, Set<any>> = new Map()

private wss?: WSServer
private portNumber?: number

async port() {
if (this.portNumber === undefined)
this.portNumber = await portfinder.getPortPromise({ port: 52000 })

return this.portNumber
}

async register(fn: string) {
const identifier = WatchNotifier.sha256(fn)

if (!this.listeners.has(identifier))
this.listeners.set(identifier, new Set())

return `ws://localhost:${await this.port()}/${identifier}`
}

sendTo(fn: string, command: string) {
if (!this.wss) return false

const sockets = this.listeners.get(WatchNotifier.sha256(fn))
if (!sockets) return false

sockets.forEach(ws => ws.send(command))
return true
}

async start(opts: ServerOptions = {}) {
this.wss = new WSServer({ ...opts, port: await this.port() })
this.wss.on('connection', (ws, sock) => {
if (sock.url) {
const [, identifier] = sock.url.split('/')
const wsSet = this.listeners.get(identifier)

if (wsSet !== undefined) {
this.listeners.set(identifier, wsSet.add(ws))
ws.on('close', () => this.listeners.get(identifier)!.delete(ws))

ws.send('ready')
return
}
}
ws.close()
})
}

stop() {
if (this.wss !== undefined) {
this.wss.close()
this.wss = undefined
}
}

static sha256(fn: string) {
const hmac = crypto.createHash('sha256')
hmac.update(fn)

return hmac.digest('hex').toString()
}
}

export const notifier = new WatchNotifier()

export default Watcher.watch

export namespace Watcher {
Expand Down
14 changes: 14 additions & 0 deletions test/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Converter, ConvertType } from '../src/converter'
import { File, FileType } from '../src/file'
import { bare as bareTpl } from '../src/templates'
import { CLIError } from '../src/error'
import { WatchNotifier } from '../src/watcher'

jest.mock('fs')

Expand Down Expand Up @@ -110,6 +111,19 @@ describe('Converter', () => {
expect(result).toContain(`<base href="${process.cwd()}">`)
})
})

context('with watch mode', () => {
const converter = instance({ watch: true })
const dummyFile = new File(process.cwd())
const hash = WatchNotifier.sha256(process.cwd())

it('adds script for auto-reload', async () => {
const { result } = await converter.convert(md, dummyFile)
expect(result).toContain(
`<script>window\.__marpCliWatchWS="ws://localhost:52000/${hash}";`
)
})
})
})

describe('#convertFile', () => {
Expand Down
2 changes: 1 addition & 1 deletion test/marp-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const coreVersion = require('@marp-team/marp-core/package.json').version
jest
.mock('fs')
.mock('mkdirp')
.mock('../src/watcher')
.mock('../src/watcher', () => jest.genMockFromModule('../src/watcher'))

afterEach(() => jest.restoreAllMocks())

Expand Down
53 changes: 53 additions & 0 deletions test/templates/watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** @jest-environment jsdom */
import watch from '../../src/templates/watch/watch'

const mockAddEventListener = jest.fn()

beforeEach(() => {
mockAddEventListener.mockReset()
;(<any>window).WebSocket = jest.fn(() => ({
addEventListener: mockAddEventListener,
}))
})

afterEach(() => jest.restoreAllMocks())

describe('Watch mode notifier on browser context', () => {
context('when window.__marpCliWatchWS is not defined', () => {
it('does not call WebSocket', () => {
watch()
expect((<any>window).WebSocket).not.toHaveBeenCalled()
})
})

context('when window.__marpCliWatchWS is not defined', () => {
beforeEach(() =>
((<any>window).__marpCliWatchWS = 'ws://localhost:52000/test'))

afterEach(() => delete (<any>window).__marpCliWatchWS)

it('calls WebSocket', () => {
watch()
expect((<any>window).WebSocket).toHaveBeenCalled()
})

it('listens message event', () => {
watch()
expect(mockAddEventListener).toHaveBeenCalledTimes(1)
expect(mockAddEventListener).toHaveBeenCalledWith(
'message',
expect.any(Function)
)

// Event callback
const reload = jest.spyOn(location, 'reload').mockImplementation()
const [, callback] = mockAddEventListener.mock.calls[0]

callback({ data: 'ready' })
expect(reload).not.toHaveBeenCalled()

callback({ data: 'reload' })
expect(reload).toHaveBeenCalled()
})
})
})
Loading

0 comments on commit c02fe2b

Please sign in to comment.