Commit 7be867d

Anton Medvedev <anton@medv.io>
2021-05-19 19:38:04
Add markdown support
1 parent 424e287
examples/basics.mjs
@@ -24,5 +24,5 @@ await $`echo ${foo} | wc` // Vars properly quoted.
 
 // We can use import and require together.
 let path = await import('path')
-let {version} = require(path.join(__dirname, '..', 'package.json'))
-console.log(chalk.black.bgYellowBright(version))
+let {name} = require(path.join(__dirname, '..', 'package.json'))
+console.log(chalk.black.bgCyanBright(name))
examples/index.md
@@ -0,0 +1,17 @@
+# Markdown Scripts
+
+It's possible to write scripts using markdown. Only code blocks will be executed
+by zx. Try to run `zx examples/index.md`.
+
+```js
+await $`whoami`
+await $`ls -la ${__dirname}`
+```
+
+The `__filename` will be pointed to **index.md**:
+
+    console.log(chalk.yellowBright(__filename))
+
+We can use imports here as well:
+
+    await import('./basics.mjs')
examples/no-extension
@@ -0,0 +1,20 @@
+#!/usr/bin/env zx
+
+// Copyright 2021 Google LLC
+// 
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// 
+//     https://www.apache.org/licenses/LICENSE-2.0
+// 
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+console.log(chalk.yellowBright`If file has no extension, zx assumes it's ESM.`)
+await $`pwd`
+console.log('__filename =', __filename)
+console.log('__dirname  =', __dirname)
index.mjs
@@ -24,7 +24,7 @@ import shq from 'shq'
 export {chalk}
 
 function colorize(cmd) {
-  return cmd.replace(/^\w+\s/, substr => {
+  return cmd.replace(/^\w+(\s|$)/, substr => {
     return chalk.greenBright(substr)
   })
 }
README.md
@@ -225,16 +225,6 @@ files (when using `zx` executable).
 let {version} = require('./package.json')
 ```
 
-### Importing from other scripts
-
-It is possible to make use of `$` and other functions via explicit imports:
-
-```js
-#!/usr/bin/env node
-import {$} from 'zx'
-await $`date`
-```
-
 ### Passing env variables
 
 ```js
@@ -253,9 +243,34 @@ let files = [...]
 await $`tar cz ${files}`
 ```
 
+### Importing from other scripts
+
+It is possible to make use of `$` and other functions via explicit imports:
+
+```js
+#!/usr/bin/env node
+import {$} from 'zx'
+await $`date`
+```
+
+### Scripts without extensions
+
+If script does not have a file extension (like `.git/hooks/pre-commit`), zx
+assumes what it is a [ESM](https://nodejs.org/api/modules.html#modules_module_createrequire_filename)
+module.
+
+### Markdown scripts
+
+The `zx` can execute scripts written in markdown 
+([examples/index.md](examples/index.md)):
+
+```bash
+zx examples/index.md
+```
+
 ### Executing remote scripts
 
-If the argument to the `zx` executable starts with `https://`, the file will be 
+If the argument to the `zx` executable starts with `https://`, the file will be
 downloaded and executed.
 
 ```bash
test.mjs
@@ -75,6 +75,18 @@ import {strict as assert} from 'assert'
   }
 }
 
+{ // Scripts with no extension are working
+  await $`node zx.mjs examples/no-extension`
+}
+
+{ // require() is working from stdin
+  await $`node zx.mjs <<< 'require("./package.json").name'`
+}
+
+{ // Markdown scripts are working
+  await $`node zx.mjs examples/index.md`
+}
+
 { // require() is working in ESM
   const {name, version} = require('./package.json')
   assert(typeof name === 'string')
zx.mjs
@@ -14,7 +14,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {join, basename, resolve, dirname} from 'path'
+import {join, basename, extname, resolve, dirname} from 'path'
 import os, {tmpdir} from 'os'
 import {promises as fs} from 'fs'
 import {createRequire} from 'module'
@@ -49,13 +49,15 @@ try {
   } else if (firstArg.startsWith('http://') || firstArg.startsWith('https://')) {
     await scriptFromHttp(firstArg)
   } else {
-    let path
-    if (firstArg.startsWith('/') || firstArg.startsWith('file:///')) {
-      path = firstArg
+    let filepath
+    if (firstArg.startsWith('/')) {
+      filepath = firstArg
+    } else if (firstArg.startsWith('file:///')) {
+      filepath = url.fileURLToPath(firstArg)
     } else {
-      path = join(process.cwd(), firstArg)
+      filepath = join(process.cwd(), firstArg)
     }
-    await importPath(path)
+    await importPath(filepath)
   }
 
 } catch (p) {
@@ -76,43 +78,97 @@ async function scriptFromStdin() {
     }
 
     if (script.length > 0) {
-      let filepath = join(tmpdir(), randomId() + '.mjs')
-      await writeAndImport(filepath, script)
+      let filepath = join(
+        tmpdir(),
+        Math.random().toString(36).substr(2) + '.mjs'
+      )
+      await fs.mkdtemp(filepath)
+      await writeAndImport(script, filepath, join(process.cwd(), 'stdin.mjs'))
       return true
     }
   }
   return false
 }
 
-async function scriptFromHttp(firstArg) {
-  let res = await fetch(firstArg)
+async function scriptFromHttp(remote) {
+  let res = await fetch(remote)
   if (!res.ok) {
-    console.error(`Error: Can't get ${firstArg}`)
+    console.error(`Error: Can't get ${remote}`)
     process.exit(1)
   }
   let script = await res.text()
-  let filepath = join(tmpdir(), basename(firstArg))
-  await writeAndImport(filepath, script)
+  let filepath = join(tmpdir(), basename(remote))
+  await fs.mkdtemp(filepath)
+  await writeAndImport(script, filepath, join(process.cwd(), basename(remote)))
 }
 
-async function writeAndImport(filepath, script) {
-  await fs.mkdtemp(filepath)
-  try {
-    await fs.writeFile(filepath, script)
-    await import(url.pathToFileURL(filepath))
-  } finally {
-    await fs.rm(filepath)
-  }
+async function writeAndImport(script, filepath, origin = filepath) {
+  await fs.writeFile(filepath, script)
+  let wait = importPath(filepath, origin)
+  await fs.rm(filepath)
+  await wait
 }
 
-async function importPath(filepath) {
-  let __filename = resolve(filepath)
+async function importPath(filepath, origin = filepath) {
+  let ext = extname(filepath)
+  if (ext === '') {
+    return await writeAndImport(
+      await fs.readFile(filepath),
+      join(dirname(filepath), basename(filepath) + '.mjs'),
+      origin,
+    )
+  }
+  if (ext === '.md') {
+    return await writeAndImport(
+      transformMarkdown((await fs.readFile(filepath)).toString()),
+      join(dirname(filepath), basename(filepath) + '.mjs'),
+      origin,
+    )
+  }
+  let __filename = resolve(origin)
   let __dirname = dirname(__filename)
-  let require = createRequire(filepath)
+  let require = createRequire(origin)
   Object.assign(global, {__filename, __dirname, require})
   await import(url.pathToFileURL(filepath))
 }
 
-function randomId() {
-  return Math.random().toString(36).substr(2)
+function transformMarkdown(source) {
+  let output = []
+  let state = 'root'
+  let prevLineIsEmpty = true
+  for (let line of source.split('\n')) {
+    switch (state) {
+      case 'root':
+        if (/^( {4}|\t)/.test(line) && prevLineIsEmpty) {
+          output.push(line)
+          state = 'tab'
+        } else if (/^```(js)?$/.test(line)) {
+          output.push('')
+          state = 'code'
+        } else {
+          prevLineIsEmpty = line === ''
+          output.push('// ' + line)
+        }
+        break
+      case 'tab':
+        if (/^( +|\t)/.test(line)) {
+          output.push(line)
+        } else if (line === '') {
+          output.push('')
+        } else {
+          output.push('// ' + line)
+          state = 'root'
+        }
+        break
+      case 'code':
+        if (/^```$/.test(line)) {
+          output.push('')
+          state = 'root'
+        } else {
+          output.push(line)
+        }
+        break
+    }
+  }
+  return output.join('\n')
 }