Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support HTML auto reloading on watch mode #20

Merged
merged 17 commits into from
Sep 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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