日々、技術的な徳を積んでいきたい erukiti です。ごきげんよう。技術同人誌 Advent Calendar 2018の 10 日目です。もう夜になってしまいましたが、まだ 12/10 です!!!
今回作ったもの
Markdown のコードブロックの JavaScript/TypeScript を実行して、実行結果を Markdown に差し込むというツールです。今朝、アイデアを思いついたので、未完成にもほどがありますが、一応、この記事を書くのには十分動いています。
モチベーション
僕がこれまでに技術書典で出してきた本は、すべて Re:VIEW で書いてきましたが、毎回サンプルコードの取り扱いがクッソかったるい面倒くさいです。
追記: Re:VIEWの場合は、#@mapfileとかで取り込んだりできるし、拡張手段もあるのと、issue で議論するのもアリなんですが、Re:VIEW でなんとかするよりも、別のエコシステムを構築したいと思いました。つまり、Re:VIEWをディスるのが目的ではないです。すみません。
編集時にサンプルコードの取り扱いをミスって動かないコードになってしまうというのは、技術書執筆あるあるなのではないでしょうか?
そこで、Markdown に書いたコードブロックを実行するというアプローチです。これなら、やろうと思えば CI にテストを組み込むことも可能でしょう。
Jupyter Notebook とか Colaboratory とかが人気ですが、個人的には GUI が気にくわないし、VSCode で書きたいので、Markdown を実行するというアプローチを取りました。
次の技術書の執筆では、この仕組みを完成させて実運用したいと思っています。
TypeScript
本記事は TypeScript で記述しています。TypeScript はとても楽です!
JavaScript を実行する
Node.js VM API には、sandbox として使える命令があります。
vm.createContext()で、コンテキスト(要はグローバル変数)を作成してから vm.runInContext()でコードを実行します。
import * as vm from 'vm'
const sampleCtx: any = {
require,
setTimeout,
setInterval,
setImmediate,
clearTimeout,
clearInterval,
clearImmediate,
process,
Buffer,
console
}
vm.createContext(sampleCtx)
vm.runInContext('const a = 10', sampleCtx)
vm.runInContext('console.log(a)', sampleCtx)
vm.runInContext('process.stdout.write("stdout\\n")', sampleCtx)
-- console.log
10
-- stdout
stdout
vm.runInContext('const a = 10', sampleCtx)を実行したときにローカル変数であるaを定義し、次のvm.runInContext('console.log(a)', sampleCtx)で引き続き同じスコープを使って、aを表示しています。
ただし、コンテキストとしてconsoleやprocessをそのまま渡すと色々不便なので、出力をトラップする仕組みを作ります。
まずはprocess.stdoutとprocess.stderrを置き換えるためのWritableStreamを作ります。
import { Writable } from 'stream'
interface Output {
name: string
value: string
}
let outputs: Output[] = []
const createWritable = name => {
return new Writable({ write: value => outputs.push({ name, value }) })
}
const stdout = createWritable('stdout')
const stderr = createWritable('stderr')
仕様としては、出力があればoutputs 配列に Output データを push します。
つぎは、Proxyを使ってprocessのstdoutとstderrを置き換えたオブジェクトを作成します。
const processProxy = new Proxy(process, {
get: (target, name) => {
switch (name) {
case 'stdout':
return stdout
case 'stderr':
return stderr
default:
return process[name]
}
}
})
同様に、consoleについても置き換えたオブジェクトを作成します。
const consoleProxy = new Proxy(console, {
get: (target, name) => {
if (!(name in console)) {
return undefined
}
return (...args) => {
outputs.push({
name: `console.${name.toString()}`,
value: args.join(' ')
})
}
}
})
あらためて Sandbox を生成する関数を作ります。
import * as babel from '@babel/core'
const createSandbox = () => {
const ctx: any = {
require,
setTimeout,
setInterval,
setImmediate,
clearTimeout,
clearInterval,
clearImmediate,
process: processProxy,
Buffer,
console: consoleProxy
}
vm.createContext(ctx)
return (code: string, filetype: string = 'js') => {
const compiled = babel.transformSync(code, {
presets: ['@babel/preset-env', '@babel/preset-typescript'],
filename: `file.${filetype}`
})
outputs = []
try {
vm.runInContext(compiled.code, ctx)
} catch (error) {
return { outputs, error: error.toString() }
}
return { outputs, error: null }
}
}
createSandbox()は、sandbox 内でコードを実行するための関数を返す関数です。
中身としては、まず Babel でコードを変換します。最近の Babel では、TypeScript も処理できるので、そのまま設定します。(ほんとは拡張子がjsなら JS 向けという風にすべきかもしれない)
変換したコード compiled.codeを実行し、出力結果やエラーを返します。
const testBox = createSandbox()
const testResult1 = testBox('const a: number = 1', 'ts')
console.log('outputs length:', testResult1.outputs.length)
console.log('outputs error:', !!testResult1.error)
const testResult2 = testBox('console.log(a + 1)', 'js')
console.log(
testResult2.outputs
.map(output => `${output.name}: ${output.value}`)
.join('\n')
)
-- console.log
outputs length: 0
-- console.log
outputs error: false
-- console.log
console.log: 2
実際に Sandbox 関数を実行するとこのようになります。
Markdown
Markdown パーサーにはremarkを使っています。
import remark from 'remark'
import math from 'remark-math'
import hljs from 'remark-highlight.js'
import breaks from 'remark-breaks'
import katex from 'remark-html-katex'
import html from 'remark-html'
const { parse } = remark()
.use(breaks)
.use(math)
.use(katex)
.use(hljs)
.use(html)
console.log(JSON.stringify(parse('hoge'), null, ' '))
-- console.log
{
"type": "root",
"children": [
{
"type": "paragraph",
"children": [
{
"type": "text",
"value": "hoge",
"position": {
"start": {
"line": 1,
"column": 1,
"offset": 0
},
"end": {
"line": 1,
"column": 5,
"offset": 4
},
"indent": []
}
}
],
"position": {
"start": {
"line": 1,
"column": 1,
"offset": 0
},
"end": {
"line": 1,
"column": 5,
"offset": 4
},
"indent": []
}
}
],
"position": {
"start": {
"line": 1,
"column": 1,
"offset": 0
},
"end": {
"line": 1,
"column": 5,
"offset": 4
}
}
}
remarkでparseすると、Markdown の AST が返ってきます。
AST はツリー構造なので再帰処理するための関数を作ります。
const traversal = (node, parent, cb, index = 0) => {
cb(node, parent, index)
;(node.children || []).forEach((child, index) =>
traversal(child, node, cb, index)
)
}
ツリーに変更を加えられるようにするため、自分の親のノードや、インデックスの数字を引き渡す仕様にしました。
つぎに、エラーや Sanbox 実行時の出力から、コードブロック(remark のノード)を生成する関数を作成します。
const createErrorNode = value => ({ type: 'code', lang: 'error', value })
const createResultNode = outputs => ({
type: 'code',
value: outputs.map(({ name, value }) => `-- ${name}\n${value}`).join('\n')
})
実行できるファイルタイプを設定します。
const lang = {
js: 'js',
javascript: 'js',
ts: 'ts',
typescript: 'ts',
jsx: 'jsx',
tsx: 'tsx'
}
実際に Markdown の中から実行できるコードブロックを探し出して実行する関数です。
const run = (markdownText: string) => {
const box = createSandbox()
const vfile = remark.parse(markdownText)
const nodes = []
traversal(vfile, vfile, (node, parent, index) => {
if (node.type === 'code') {
const filetype = node.lang || 'js'
const { outputs, error } = box(node.value, filetype)
if (error) {
nodes.push({ parent, index, node: createErrorNode(error) })
} else if (outputs.length > 0) {
nodes.push({ parent, index, node: createResultNode(outputs) })
}
}
})
nodes.reverse().forEach(({ parent, index, node }) => {
parent.children = [
...parent.children.slice(0, index + 1),
node,
...parent.children.slice(index + 1)
]
})
return { vfile }
}
最後の nodes.reverse() している部分は、生成したコードブロックを挿入するときに、インデックスがズレないようにするための小細工です。JavaScript では配列に挿入するメソッドがないので、スプレッド演算子と、Array.sliceメソッドを駆使して挿入をしています。
返しているvfileは、Remark(正確にはそれのベースであるunifiedライブラリ)でのトップレベルノードです。
Markdown を実行して書き換えた Markdown を出力する
一通りのパーツがそろったので、実際に Markdown の中のコードブロックを実行して、Markdown を出力するコードです。
import unified from 'unified'
import stringify from 'remark-stringify'
import { run } from './run'
const convert = (text: string) => {
const { results, vfile } = run(text)
return unified()
.use(stringify)
.stringify(vfile)
}
let mdText = '```js\n'
mdText += 'const a = 1\n'
mdText += '```\n'
mdText += '`a`に`1`を設定します。\n'
mdText += '```js\n'
mdText += 'console.log(a)\n'
mdText += '```\n'
mdText += '`a`の中身を出力します。\n'
console.log(convert(mdText))
-- console.log
```js
const a = 1
```
`a`に`1`を設定します。
```js
console.log(a)
```
-- console.log
1
`a`の中身を出力します。
最後に
ちなみにこの記事の Markdown は、実際に今回つくった actual-code で作成したものです。
まとめ
- サンプルコードを確実にするためには、Markdown の中に直接コードを書いて、そのまま実行したい
- Node.js では VM API を使って実現できる(Jupyter の Node.js 向け kernel でも使われてるやりかた)
- Markdown パーサーには remark を使った
ライセンス
ライセンスは MIT です。
今後
色々手を加えたいところはあります。
- 他の言語も実行できるようにする
- ブラウザ向けのコードを実行できるようにする
- 画像とかグラフとかいい感じに
- VSCode 拡張とか
- Re:VIEW コンバータとか
リポジトリ
追記
shell実行機能もつけてみました。
を実行すると、
# How to use uuidv4
## install
```sh
$ yarn init -y
$ yarn add uuidv4
```
-- yarn init -y
yarn init v1.10.1
warning The yes flag has been set. This will automatically answer yes to all questions, which may have security implications.
success Saved package.json
Done in 0.04s.
-- yarn add uuidv4
yarn add v1.10.1
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved 1 new dependency.
info Direct dependencies
└─ uuidv4@2.0.0
info All dependencies
└─ uuidv4@2.0.0
Done in 0.30s.
## JS
```js
const uuidv4 = require('uuidv4')
console.log(uuidv4())
```
-- console.log
ac68281c-ebfb-4990-9c70-e24ec5dfa14e
という感じになります。