Skip to content
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 `--html` option ([#7](https://github.com/marp-team/marp-cli/pull/7))
- Render local resources in converting PDF by `--allow-local-files option` ([#10](https://github.com/marp-team/marp-cli/pull/10))

### Changed

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"is-wsl": "^1.1.0",
"os-locale": "^3.0.1",
"puppeteer-core": "^1.7.0",
"tmp": "^0.0.33",
"yargs": "^12.0.1"
}
}
81 changes: 54 additions & 27 deletions src/converter.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Marpit, MarpitOptions } from '@marp-team/marpit'
import * as chromeFinder from 'chrome-launcher/dist/chrome-finder'
import puppeteer, { PDFOptions } from 'puppeteer-core'
import { warn } from './cli'
import { error } from './error'
import { File } from './file'
import { File, FileType } from './file'
import templates, { TemplateResult } from './templates/'

export enum ConvertType {
Expand All @@ -11,6 +12,7 @@ export enum ConvertType {
}

export interface ConverterOption {
allowLocalFiles: boolean
engine: typeof Marpit
html?: boolean
lang: string
Expand Down Expand Up @@ -42,45 +44,35 @@ export class Converter {
return template
}

async convert(markdown: string): Promise<TemplateResult> {
async convert(markdown: string, file?: File): Promise<TemplateResult> {
const { lang, readyScript, theme, type } = this.options

let additionals = ''
let base

if (theme) additionals += `\n<!-- theme: ${JSON.stringify(theme)} -->`

if (this.options.theme)
additionals += `\n<!-- theme: ${JSON.stringify(this.options.theme)} -->`
if (type === ConvertType.pdf && file && file.type === FileType.File)
base = file.absolutePath

return await this.template({
lang: this.options.lang,
readyScript: this.options.readyScript,
base,
lang,
readyScript,
renderer: tplOpts =>
this.generateEngine(tplOpts).render(`${markdown}${additionals}`),
})
}

async convertFile(file: File): Promise<ConvertResult> {
const buffer = await file.load()
const template = await this.convert(buffer.toString())
const template = await this.convert(buffer.toString(), file)
const newFile = file.convert(this.options.output, this.options.type)

newFile.buffer = await (async () => {
if (this.options.type === ConvertType.pdf) {
const browser = await Converter.runBrowser()

try {
const page = await browser.newPage()
await page.goto(`data:text/html,${template.result}`, {
waitUntil: ['domcontentloaded', 'networkidle0'],
})

return await page.pdf(<PDFOptions>{
printBackground: true,
preferCSSPageSize: true,
})
} finally {
await browser.close()
}
}
return new Buffer(template.result)
})()
newFile.buffer = new Buffer(template.result)

if (this.options.type === ConvertType.pdf)
await this.convertFileToPDF(newFile)

await newFile.save()
return { file, newFile, template }
Expand All @@ -96,6 +88,41 @@ export class Converter {
for (const file of files) onConverted(await this.convertFile(file))
}

private async convertFileToPDF(file: File) {
const tmpFile: File.TmpFileInterface | undefined = await (() => {
if (!this.options.allowLocalFiles) return undefined

const warning = `Insecure local file accessing is enabled for conversion of ${file.relativePath()}.`
warn(warning)

return file.saveTmpFile('.html')
})()

const uri = tmpFile
? `file://${tmpFile.path}`
: `data:text/html,${file.buffer!.toString()}`

try {
const browser = await Converter.runBrowser()

try {
const page = await browser.newPage()
await page.goto(uri, {
waitUntil: ['domcontentloaded', 'networkidle0'],
})

file.buffer = await page.pdf(<PDFOptions>{
printBackground: true,
preferCSSPageSize: true,
})
} finally {
await browser.close()
}
} finally {
if (tmpFile) await tmpFile.cleanup()
}
}

private generateEngine(mergeOptions: MarpitOptions) {
const { html, options } = this.options
const opts: any = { ...options, ...mergeOptions }
Expand Down
41 changes: 38 additions & 3 deletions src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'fs'
import getStdin from 'get-stdin'
import globby from 'globby'
import path from 'path'
import { tmpName } from 'tmp'

const markdownExtensions = ['*.md', '*.mdown', '*.markdown', '*.markdn']

Expand Down Expand Up @@ -53,22 +54,49 @@ export class File {
async save() {
switch (this.type) {
case FileType.File:
await new Promise<void>((resolve, reject) =>
fs.writeFile(this.path, this.buffer, e => (e ? reject(e) : resolve()))
)
await this.saveToFile()
break
case FileType.StandardIO:
process.stdout.write(this.buffer!)
}
}

async saveTmpFile(ext?: string): Promise<File.TmpFileInterface> {
const path: string = await new Promise<string>((resolve, reject) => {
tmpName({ postfix: ext }, (e, path) => (e ? reject(e) : resolve(path)))
})

await this.saveToFile(path)

return {
path,
cleanup: async () => {
try {
await this.cleanup(path)
} catch (e) {}
},
}
}

private async cleanup(path: string) {
return new Promise<void>((resolve, reject) =>
fs.unlink(path, e => (e ? reject(e) : resolve()))
)
}

private convertExtension(extension: string): string {
return path.join(
path.dirname(this.path),
`${path.basename(this.path, path.extname(this.path))}.${extension}`
)
}

private async saveToFile(path: string = this.path) {
return new Promise<void>((resolve, reject) =>
fs.writeFile(path, this.buffer, e => (e ? reject(e) : resolve()))
)
}

private static stdinBuffer?: Buffer

static async find(...pathes: string[]): Promise<File[]> {
Expand Down Expand Up @@ -97,3 +125,10 @@ export class File {
return instance
}
}

export namespace File {
export interface TmpFileInterface {
path: string
cleanup: () => Promise<void>
}
}
8 changes: 8 additions & 0 deletions src/marp-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export default async function(argv: string[] = []): Promise<number> {
choices: Object.keys(templates),
type: 'string',
},
'allow-local-files': {
default: false,
describe:
'Allow to access local files from Markdown while converting PDF (INSECURE)',
group: OptionGroup.Converter,
type: 'boolean',
},
html: {
describe: 'Enable or disable HTML tag',
group: OptionGroup.Marp,
Expand All @@ -82,6 +89,7 @@ export default async function(argv: string[] = []): Promise<number> {

// Initialize converter
const converter = new Converter({
allowLocalFiles: args.allowLocalFiles,
engine: Marp,
html: args.html,
lang: (await osLocale()).replace(/[_@]/g, '-'),
Expand Down
3 changes: 3 additions & 0 deletions src/templates/bare/bare.pug
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
doctype
html(lang=lang)
head
if base
base(href=base)

meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
meta(http-equiv="X-UA-Compatible", content="ie=edge")
Expand Down
3 changes: 3 additions & 0 deletions src/templates/bespoke/bespoke.pug
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
doctype
html(lang=lang)
head
if base
base(href=base)

meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
meta(http-equiv="X-UA-Compatible", content="ie=edge")
Expand Down
1 change: 1 addition & 0 deletions src/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import bespokePug from './bespoke/bespoke.pug'
import bespokeScss from './bespoke/bespoke.scss'

export interface TemplateOptions {
base?: string
lang: string
readyScript?: string
renderer: (tplOpts: MarpitOptions) => MarpitRenderResult
Expand Down
60 changes: 58 additions & 2 deletions test/converter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Marp from '@marp-team/marp-core'
import { MarpitOptions } from '@marp-team/marpit'
import fs from 'fs'
import os from 'os'
import path from 'path'
import context from './_helpers/context'
import { useSpy } from './_helpers/spy'
Expand All @@ -16,6 +17,7 @@ describe('Converter', () => {

const instance = (opts = {}) =>
new Converter({
allowLocalFiles: false,
lang: 'en',
engine: Marp,
options: {},
Expand All @@ -27,7 +29,8 @@ describe('Converter', () => {
describe('#constructor', () => {
it('assigns initial options to options member', () => {
const options = {
lang: 'en',
allowLocalFiles: true,
lang: 'fr',
engine: Marp,
options: <MarpitOptions>{ html: true },
template: 'test-template',
Expand Down Expand Up @@ -61,6 +64,7 @@ describe('Converter', () => {
expect(result.result).toContain(result.rendered.html)
expect(result.result).toContain(result.rendered.css)
expect(result.result).toContain(readyScript)
expect(result.result).not.toContain('<base')
expect(result.rendered.css).toContain('@theme default')
})

Expand Down Expand Up @@ -91,6 +95,16 @@ describe('Converter', () => {
const disabled = (await instance({ html: false }).convert(md)).rendered
expect(disabled.html).toContain('&lt;i&gt;Hello!&lt;/i&gt;')
})

context('with PDF convert type', () => {
const converter = instance({ type: ConvertType.pdf })
const dummyFile = new File(process.cwd())

it('adds <base> element with specified base path from passed file', async () => {
const { result } = await converter.convert(md, dummyFile)
expect(result).toContain(`<base href="${process.cwd()}">`)
})
})
})

describe('#convertFile', () => {
Expand Down Expand Up @@ -170,11 +184,53 @@ describe('Converter', () => {
expect(write.mock.calls[0][0]).toBe('test.pdf')
expect(pdf.toString('ascii', 0, 5)).toBe('%PDF-')
expect(ret.newFile.path).toBe('test.pdf')
expect(ret.newFile.buffer).toBe(write.mock.calls[0][1])
expect(ret.newFile.buffer).toBe(pdf)
})
},
10000
)

context('with allowLocalFiles option as true', () => {
it(
'converts into PDF by using temporally file',
async () => {
const file = new File(onePath)

// @ts-ignore to check cleanup tmpfile
const fileCleanup = jest.spyOn(File.prototype, 'cleanup')
const fileSave = jest.spyOn(File.prototype, 'save')
const fileTmp = jest.spyOn(File.prototype, 'saveTmpFile')

const warn = jest.spyOn(console, 'warn')

return useSpy(
[fileCleanup, fileSave, fileTmp, warn],
async () => {
fileSave.mockImplementation()
warn.mockImplementation()

await pdfInstance({
allowLocalFiles: true,
output: '-',
}).convertFile(file)

expect(warn).toBeCalledWith(
expect.stringContaining(
'Insecure local file accessing is enabled'
)
)
expect(fileTmp).toBeCalledWith('.html')
expect(fileCleanup).toBeCalledWith(
expect.stringContaining(os.tmpdir())
)
expect(fileSave).toBeCalled()
},
false
)
},
10000
)
})
})
})

Expand Down
8 changes: 7 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4207,7 +4207,7 @@ os-locale@^3.0.1:
lcid "^2.0.0"
mem "^4.0.0"

os-tmpdir@^1.0.0, os-tmpdir@^1.0.1:
os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"

Expand Down Expand Up @@ -6244,6 +6244,12 @@ tippex@^2.1.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/tippex/-/tippex-2.3.1.tgz#a2fd5b7087d7cbfb20c9806a6c16108c2c0fafda"

tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
dependencies:
os-tmpdir "~1.0.2"

[email protected]:
version "1.0.4"
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
Expand Down