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

Add watch mode #18

Merged
merged 6 commits into from
Sep 16, 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

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

## v0.0.8 - 2018-09-15

### Added
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ Under construction.
- [ ] Import external theme file(s)
- [x] Select theme by option
- [x] Support configuration file (like `.marprc`)
- [ ] Watch mode
- [x] Watch mode
- [ ] 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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
},
"devDependencies": {
"@babel/core": "^7.0.1",
"@types/chokidar": "^1.7.5",
"@types/cosmiconfig": "^5.0.3",
"@types/get-stdin": "^5.0.1",
"@types/jest": "^23.3.2",
Expand Down Expand Up @@ -89,6 +90,7 @@
"@marp-team/marp-core": "^0.0.7",
"@marp-team/marpit": "^0.1.0",
"chalk": "^2.4.1",
"chokidar": "^2.0.4",
"chrome-launcher": "^0.10.2",
"cosmiconfig": "^5.0.6",
"get-stdin": "^6.0.0",
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface IMarpCLIArguments {
pdf?: boolean
template?: string
theme?: string
watch?: boolean
}

export type IMarpCLIConfig = Overwrite<
Expand Down Expand Up @@ -88,6 +89,7 @@ export class MarpCLIConfig {
`${output}`.toLowerCase().endsWith('.pdf')
? ConvertType.pdf
: ConvertType.html,
watch: this.pickDefined(this.args.watch || this.conf.watch) || false,
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface ConverterOption {
template: string
theme?: string
type: ConvertType
watch: boolean
}

export interface ConvertResult {
Expand All @@ -33,6 +34,8 @@ export interface ConvertResult {
template: TemplateResult
}

export type ConvertedCallback = (result: ConvertResult) => void

export class Converter {
readonly options: ConverterOption

Expand Down Expand Up @@ -83,7 +86,7 @@ export class Converter {

async convertFiles(
files: File[],
onConverted: (result: ConvertResult) => void = () => {}
onConverted: ConvertedCallback = () => {}
): Promise<void> {
const { inputDir, output } = this.options

Expand Down
50 changes: 36 additions & 14 deletions src/marp-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { Argv } from 'yargs'
import yargs from 'yargs/yargs'
import * as cli from './cli'
import fromArguments, { IMarpCLIArguments } from './config'
import { Converter } from './converter'
import { Converter, ConvertedCallback } from './converter'
import { CLIError, error } from './error'
import { File, FileType } from './file'
import templates from './templates'
import watcher from './watcher'
import { name, version } from '../package.json'

enum OptionGroup {
Expand Down Expand Up @@ -59,6 +60,12 @@ export default async function(argv: string[] = []): Promise<number> {
group: OptionGroup.Basic,
type: 'string',
},
watch: {
alias: 'w',
describe: 'Watch input markdowns for changes',
group: OptionGroup.Basic,
type: 'boolean',
},
pdf: {
describe: 'Convert slide deck into PDF',
group: OptionGroup.Converter,
Expand Down Expand Up @@ -102,27 +109,28 @@ export default async function(argv: string[] = []): Promise<number> {

// Initialize converter
const config = await fromArguments(<IMarpCLIArguments>args)
const converterOpts = await config.converterOption()
const converter = new Converter(converterOpts)
const converter = new Converter(await config.converterOption())
const cvtOpts = converter.options

// Find target markdown files
const files = await (async (): Promise<File[]> => {
if (converterOpts.inputDir) {
const finder = async () => {
if (cvtOpts.inputDir) {
if (args._.length > 0) {
cli.error('Cannot pass files together with input directory.')
return []
}

// Find directory to keep dir structure of input dir in output
return File.findDir(converterOpts.inputDir)
return File.findDir(cvtOpts.inputDir)
}

// Regular file finding powered by globby
return <File[]>(
[await File.stdin(), ...(await File.find(...args._))].filter(f => f)
)
})()
}

const files = await finder()
const { length } = files

if (length === 0) {
Expand All @@ -136,18 +144,32 @@ export default async function(argv: string[] = []): Promise<number> {
cli.info(`Converting ${length} markdown${length > 1 ? 's' : ''}...`)

// Convert markdown into HTML
try {
await converter.convertFiles(files, ret => {
const { file, newFile } = ret
const output = (f: File, io: string) =>
f.type === FileType.StandardIO ? `<${io}>` : f.relativePath()
const onConverted: ConvertedCallback = ret => {
const { file, newFile } = ret
const output = (f: File, io: string) =>
f.type === FileType.StandardIO ? `<${io}>` : f.relativePath()

cli.info(`${output(file, 'stdin')} => ${output(newFile, 'stdout')}`)
})
cli.info(`${output(file, 'stdin')} => ${output(newFile, 'stdout')}`)
}

try {
await converter.convertFiles(files, onConverted)
} catch (e) {
error(`Failed converting Markdown. (${e.message})`, e.errorCode)
}

// Watch mode
if (cvtOpts.watch) {
watcher(cvtOpts.inputDir || args._, {
converter,
finder,
events: {
onConverted,
onError: e => cli.error(`Failed converting Markdown. (${e.message})`),
},
})
}

return 0
} catch (e) {
if (!(e instanceof CLIError)) throw e
Expand Down
58 changes: 58 additions & 0 deletions src/watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import chalk from 'chalk'
import chokidar from 'chokidar'
import path from 'path'
import * as cli from './cli'
import { Converter, ConvertedCallback } from './converter'
import { File } from './file'

export class Watcher {
chokidar: chokidar.FSWatcher

readonly converter: Converter
readonly events: Watcher.Events
readonly finder: Watcher.Options['finder']

private constructor(watchPath: string | string[], opts: Watcher.Options) {
this.converter = opts.converter
this.finder = opts.finder
this.events = opts.events

this.chokidar = chokidar.watch(watchPath, { ignoreInitial: true })
this.chokidar
.on('change', f => this.convert(f))
.on('add', f => this.convert(f))

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

private async convert(fn) {
const files = (await this.finder()).filter(
f => path.resolve(f.path) === path.resolve(fn)
)

try {
await this.converter.convertFiles(files, this.events.onConverted)
} catch (e) {
this.events.onError(e)
}
}

static watch(watchPath: string | string[], opts: Watcher.Options) {
return new Watcher(watchPath, opts)
}
}

export default Watcher.watch

export namespace Watcher {
export interface Options {
converter: Converter
events: Watcher.Events
finder: () => Promise<File[]>
}

export interface Events {
onConverted: ConvertedCallback
onError: (e: Error) => void
}
}
2 changes: 2 additions & 0 deletions test/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('Converter', () => {
options: {},
template: 'bare',
type: ConvertType.html,
watch: false,
...opts,
})

Expand All @@ -37,6 +38,7 @@ describe('Converter', () => {
options: <MarpitOptions>{ html: true },
template: 'test-template',
type: ConvertType.html,
watch: false,
}

expect(new Converter(options).options).toMatchObject(options)
Expand Down
16 changes: 15 additions & 1 deletion test/marp-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import * as cli from '../src/cli'
import { File } from '../src/file'
import { Converter, ConvertType } from '../src/converter'
import { CLIError } from '../src/error'
import { Watcher } from '../src/watcher'

const { version } = require('../package.json')
const coreVersion = require('@marp-team/marp-core/package.json').version

jest.mock('fs').mock('mkdirp')
jest
.mock('fs')
.mock('mkdirp')
.mock('../src/watcher')

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

Expand Down Expand Up @@ -208,6 +212,16 @@ describe('Marp CLI', () => {
)
})

context('with -w option', () => {
it('starts watching by Watcher.watch()', async () => {
jest.spyOn(cli, 'info').mockImplementation()
;(<any>fs).__mockWriteFile()

expect(await marpCli([onePath, '-w'])).toBe(0)
expect(Watcher.watch).toHaveBeenCalledWith([onePath], expect.anything())
})
})

context('with configuration file', () => {
it('uses configuration file found from process.cwd()', async () => {
const stdout = jest.spyOn(process.stdout, 'write').mockImplementation()
Expand Down
59 changes: 59 additions & 0 deletions test/watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import chokidar from 'chokidar'
import * as cli from '../src/cli'
import { File } from '../src/file'
import { Watcher } from '../src/watcher'

jest.mock('chokidar', () => ({
watch: jest.fn(() => ({
on: jest.fn(function() {
return this
}),
})),
}))

describe('Watcher', () => {
describe('.watch', () => {
const { watch } = Watcher
const convertFiles = jest.fn()
const converterStub = { convertFiles: convertFiles.mockResolvedValue(0) }

beforeEach(() => jest.spyOn(cli, 'info').mockImplementation())

it('starts watching passed path by chokidar', async () => {
const events = { onConverted: jest.fn(), onError: jest.fn() }
const file = new File('test.md')
const watcher = watch('test.md', <any>{
events,
converter: converterStub,
finder: async () => [file],
})

expect(watcher).toBeInstanceOf(Watcher)
expect(chokidar.watch).toHaveBeenCalledWith('test.md', expect.anything())

const { on } = watcher.chokidar
expect(on).toHaveBeenCalledWith('change', expect.any(Function))
expect(on).toHaveBeenCalledWith('add', expect.any(Function))

// Trigger converter if filepath is matched to finder files
for (const [, convertFunc] of (<jest.Mock>on).mock.calls) {
convertFiles.mockClear()
await convertFunc('test.md')
expect(convertFiles).toHaveBeenCalledTimes(1)

const [files, onConverted] = convertFiles.mock.calls[0]
expect(files).toContain(file)
expect(onConverted).toBe(events.onConverted)

// Error handling
events.onError.mockClear()
convertFiles.mockImplementationOnce(() => {
throw new Error('Error occurred')
})

await convertFunc('test.md')
expect(events.onError).toHaveBeenCalledTimes(1)
}
})
})
})
Loading