├── .babelrc ├── .eslintignore ├── .eslintrc.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin └── pen ├── index.js ├── media └── logo.png ├── package-lock.json ├── package.json ├── src ├── argv.js ├── frontend │ ├── .eslintrc.yml │ ├── html-renderer.js │ ├── main.js │ ├── socket-client.js │ └── style.css ├── markdown-socket.js ├── markdown-watcher.js ├── markdown.js ├── server.js └── watcher.js ├── test ├── .eslintrc.yml ├── lib │ └── helper.js ├── setup.js ├── test-build-html.js ├── test-html-renderer.js ├── test-index.js ├── test-markdown-socket.js ├── test-markdown-watcher.js ├── test-server.js ├── test-socket-client.js └── test-watcher.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Built files 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | parser: "babel-eslint" 3 | extends: 4 | - "eslint:recommended" 5 | - "plugin:prettier/recommended" 6 | rules: 7 | no-console: 0 8 | env: 9 | node: true 10 | es6: true 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Temporary files for tests 30 | /test/temp 31 | 32 | # Built files 33 | /dist 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /.eslintignore 4 | /.eslintrc 5 | /.gitignore 6 | /.travis.yml 7 | /test/temp 8 | /resource 9 | 10 | # Built JS and CSS files 11 | /dist/build.js 12 | /dist/build.css 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 6 4 | - 8 5 | - node 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2018 Jun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

logo

2 | 3 | > We need a better Markdown previewer. 4 | 5 | [![travis](https://travis-ci.org/utatti/pen.svg)](https://travis-ci.org/utatti/pen) 6 | 7 | `pen` is a Markdown previewer written in JavaScript, aiming to *just work*. 8 | 9 | There are literally tons of Markdown previewers out there. I love some of them, 10 | I even made [one](https://github.com/utatti/orange-cat) of them. Nevertheless, 11 | we always need a better one, don't we? 12 | 13 | Using `pen` is super simple, we don't need to install any special editor or 14 | launch any GUI application. `pen` is just a tidy command-line tool. You can use 15 | your favourite editor and browser. No manual refresh is even needed. 16 | 17 | Also, the previewer renders the content using [React](https://facebook.github.io/react/). 18 | It means that it will not re-render entire DOM when the document is updated. 19 | This is a huge advantage because images or other media won't be reloaded for 20 | the DOM update. 21 | 22 | I personally love to use `pen`, and I hope you love it too. :black_nib: 23 | 24 | ## Demo 25 | 26 | Here is a short demo showing how awesome `pen` is. 27 | 28 | ![demo](https://cloud.githubusercontent.com/assets/1013641/9977359/21b79f66-5f3f-11e5-860a-cf19b2287009.gif) 29 | 30 | The following demo shows `pen` incrementally updates only modified part using 31 | [React](https://facebook.github.io/react/) and its [Reconciliation](https://reactjs.org/docs/reconciliation.html). 32 | 33 | ![incremental update](https://cloud.githubusercontent.com/assets/1013641/11914823/896591ba-a6cd-11e5-94ee-05e3ab50413b.gif) 34 | 35 | ## Requirement 36 | 37 | `pen` uses [Node.js >= 4.0](https://nodejs.org/en/docs/es6/). It may not work 38 | on earlier versions. 39 | 40 | ## Install 41 | 42 | Using [npm](http://npmjs.com): 43 | 44 | ```shell 45 | npm i -g pen 46 | ``` 47 | 48 | You can try using `pen` with `npx`: 49 | 50 | ```shell 51 | npx pen 52 | ``` 53 | 54 | ## Usage 55 | 56 | To use `pen`, simply run the `pen` command. 57 | 58 | ```shell 59 | pen README.md 60 | ``` 61 | 62 | The command above will launch a `pen` server and open the file in your default 63 | browser. The server will listen to a 6060 port by default. To be honest, you 64 | don't even need to launch it with a filename. You can manually open 65 | http://localhost:6060/README.md, or any other files in the same directory. 66 | 67 | To stop the server, enter `^C`. 68 | 69 | For the further details of the `pen` command, please enter `pen -h` or `pen 70 | --help`. 71 | 72 | ### Pandoc 73 | 74 | Pen uses [markdown-it](https://github.com/markdown-it/markdown-it) as Markdown 75 | parser by default, but it also supports Pandoc. Please provide [a proper Pandoc 76 | format](http://pandoc.org/MANUAL.html#general-options) for the value. 77 | 78 | ```shell 79 | pen --pandoc gfm README.md 80 | ``` 81 | 82 | ## Contribution 83 | 84 | I welcome every contribution on `pen`. You may start from forking and cloning 85 | this repo. 86 | 87 | ```shell 88 | git clone git@github.com:your_username/pen.git 89 | cd pen 90 | 91 | # Install dependencies 92 | npm i 93 | 94 | # Lint, build, and test pen codes at once 95 | npm test 96 | ``` 97 | 98 | To build frontend scripts: 99 | 100 | ```shell 101 | npm run build 102 | ``` 103 | 104 | To lint with [ESLint](http://eslint.org): 105 | 106 | ```shell 107 | npm run lint 108 | ``` 109 | 110 | To test with [Mocha](http://mochajs.org) 111 | 112 | ```shell 113 | npm run mocha 114 | ``` 115 | 116 | ## License 117 | 118 | Pen is released under the [MIT License](LICENSE). 119 | -------------------------------------------------------------------------------- /bin/pen: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../index'); 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const open = require("opn"); 4 | const Server = require("./src/server"); 5 | const argv = require("./src/argv"); 6 | 7 | let server = new Server(process.cwd()); 8 | server.listen(argv.port, () => { 9 | console.log(`listening ${argv.port} ...`); 10 | 11 | argv._.forEach(file => 12 | open(`http://localhost:${argv.port}/${file}`).catch(() => {}) 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hatashiro/pen/5b1e0123c25c37b9fc26dec9f91aa9c27539b505/media/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pen", 3 | "version": "2.2.0", 4 | "description": "A better Markdown previewer", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npm run lint && npm run build && npm run mocha", 8 | "lint": "eslint --ext .js --ignore-path .gitignore .", 9 | "lintfix": "eslint --fix --ext .js --ignore-path .gitignore .", 10 | "build": "NODE_ENV=production webpack -p", 11 | "mocha": "mocha -r babel-core/register -r babel-polyfill -r test/setup.js test/**/test-*", 12 | "release": "npm run build && npm publish --access=public" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/utatti/pen.git" 17 | }, 18 | "keywords": [ 19 | "markdown", 20 | "previewer" 21 | ], 22 | "author": "Jun ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/utatti/pen/issues" 26 | }, 27 | "homepage": "https://github.com/utatti/pen", 28 | "devDependencies": { 29 | "babel-core": "^6.26.3", 30 | "babel-eslint": "^8.2.3", 31 | "babel-loader": "^7.1.2", 32 | "babel-polyfill": "^6.26.0", 33 | "babel-preset-env": "^1.7.0", 34 | "css-loader": "^0.28.7", 35 | "eslint": "^4.19.1", 36 | "eslint-config-prettier": "^2.9.0", 37 | "eslint-plugin-prettier": "^2.6.0", 38 | "eslint-plugin-react": "^7.7.0", 39 | "extract-text-webpack-plugin": "^3.0.2", 40 | "github-markdown-css": "^2.9.0", 41 | "html-webpack-inline-source-plugin": "^0.0.9", 42 | "html-webpack-plugin": "^2.30.1", 43 | "jsdom": "^9.4.2", 44 | "json-loader": "^0.5.7", 45 | "mocha": "^5.2.0", 46 | "prettier": "^1.12.1", 47 | "react": "^16.2.0", 48 | "react-dom": "^16.2.0", 49 | "react-render-html": "^0.6.0", 50 | "request": "^2.87.0", 51 | "rimraf": "^2.5.4", 52 | "style-loader": "^0.19.0", 53 | "webpack": "^3.8.1", 54 | "webpack-fail-plugin": "^2.0.0" 55 | }, 56 | "dependencies": { 57 | "highlight.js": "^9.9.0", 58 | "markdown-it": "^8.2.2", 59 | "markdown-it-anchor": "^5.0.2", 60 | "markdown-it-checkbox": "^1.1.0", 61 | "markdown-it-emoji": "^1.2.0", 62 | "markdown-it-highlightjs": "^2.0.0", 63 | "opn": "^4.0.2", 64 | "prop-types": "^15.6.0", 65 | "simple-pandoc": "^0.1.0", 66 | "websocket": "^1.0.26", 67 | "yargs": "^6.6.0" 68 | }, 69 | "preferGlobal": true, 70 | "bin": { 71 | "pen": "./bin/pen" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/argv.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const DEFAULT_PORT = 6060; 4 | 5 | module.exports = require("yargs") 6 | .usage("Usage: $0 [options] [file]") 7 | .option("p", { 8 | alias: "port", 9 | default: DEFAULT_PORT, 10 | describe: "Set a custom port" 11 | }) 12 | .option("pandoc", { 13 | type: "string", 14 | describe: 15 | "Use local pandoc as markdown converter. Provide target format as the value." 16 | }) 17 | .help("h") 18 | .alias("h", "help").argv; 19 | -------------------------------------------------------------------------------- /src/frontend/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | sourceType: module 3 | extends: 4 | - "eslint:recommended" 5 | - "plugin:react/recommended" 6 | env: 7 | browser: true 8 | -------------------------------------------------------------------------------- /src/frontend/html-renderer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import renderHTML from "react-render-html"; 3 | import SocketClient from "./socket-client"; 4 | import PropTypes from "prop-types"; 5 | 6 | class HTMLRenderer extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { html: "" }; 10 | } 11 | 12 | componentDidMount() { 13 | this.socketClient = new SocketClient(this.props.location); 14 | this.socketClient.onData(html => this.setState({ html })); 15 | } 16 | 17 | componentDidUpdate() { 18 | if (this.props.onUpdate) { 19 | this.props.onUpdate(); 20 | } 21 | } 22 | 23 | render() { 24 | return React.createElement("div", null, renderHTML(this.state.html)); 25 | } 26 | } 27 | 28 | HTMLRenderer.propTypes = { 29 | location: PropTypes.shape({ 30 | host: PropTypes.string.isRequired, 31 | pathname: PropTypes.string.isRequired 32 | }).isRequired, 33 | onUpdate: PropTypes.func 34 | }; 35 | 36 | export default HTMLRenderer; 37 | -------------------------------------------------------------------------------- /src/frontend/main.js: -------------------------------------------------------------------------------- 1 | // Non-js dependencies 2 | import "github-markdown-css/github-markdown.css"; 3 | import "highlight.js/styles/foundation.css"; 4 | import "./style.css"; 5 | 6 | import React from "react"; 7 | import ReactDOM from "react-dom"; 8 | import HTMLRenderer from "./html-renderer"; 9 | 10 | const app = document.createElement("div"); 11 | app.setAttribute("id", "app"); 12 | app.setAttribute("class", "markdown-body"); 13 | document.body.appendChild(app); 14 | 15 | // Set title to Markdown filename 16 | const pathTokens = location.pathname.split("/"); 17 | document.title = pathTokens[pathTokens.length - 1]; 18 | 19 | ReactDOM.render(React.createElement(HTMLRenderer, { location }), app); 20 | -------------------------------------------------------------------------------- /src/frontend/socket-client.js: -------------------------------------------------------------------------------- 1 | import { w3cwebsocket as WebSocket } from "websocket"; 2 | 3 | export default class SocketClient { 4 | constructor(location) { 5 | this.host = location.host; 6 | this.pathname = location.pathname; 7 | 8 | const url = `ws://${this.host}${this.pathname}`; 9 | 10 | this._socket = new WebSocket(url); 11 | this._socket.onmessage = event => { 12 | this.triggerOnData(event.data); 13 | }; 14 | 15 | this._dataCallback = null; 16 | } 17 | 18 | onData(callback) { 19 | this._dataCallback = callback; 20 | return this; 21 | } 22 | 23 | triggerOnData(data) { 24 | if (this._dataCallback) { 25 | this._dataCallback(data); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/frontend/style.css: -------------------------------------------------------------------------------- 1 | #app { 2 | max-width: 790px; 3 | margin: 30px auto; 4 | } 5 | 6 | /* checkbox */ 7 | input[type='checkbox'] { 8 | vertical-align: middle; 9 | margin: 0 0.5em 0.25em 0; 10 | } 11 | li:has(> input[type='checkbox']) { 12 | list-style-type:none; 13 | } 14 | li>input[type='checkbox'] { 15 | margin: 0 0.7em 0.25em -1.6em; 16 | } 17 | -------------------------------------------------------------------------------- /src/markdown-socket.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const MarkdownWatcher = require("./markdown-watcher"); 4 | const path = require("path"); 5 | const WebSocketServer = require("websocket").server; 6 | 7 | class MarkdownSocket { 8 | constructor(rootPath) { 9 | this.rootPath = rootPath; 10 | this._server = null; 11 | this.pathname = null; 12 | } 13 | 14 | listenTo(httpServer) { 15 | this._server = new WebSocketServer(); 16 | this._server.mount({ httpServer: httpServer }); 17 | this._server.on("request", this.onRequest.bind(this)); 18 | this._server.on("connect", this.onConnect.bind(this)); 19 | } 20 | 21 | onRequest(request) { 22 | const extname = path.extname(request.resource); 23 | 24 | if (extname !== ".md" && extname !== ".markdown") { 25 | request.reject(); 26 | return; 27 | } 28 | 29 | this.pathname = request.resource; 30 | request.accept(null, request.origin); 31 | } 32 | 33 | onConnect(connection) { 34 | const decodedPath = decodeURIComponent(this.pathname); 35 | const watcher = new MarkdownWatcher(path.join(this.rootPath, decodedPath)); 36 | watcher.onData(data => connection.send(data)); 37 | watcher.onError(err => { 38 | if (err.code === "ENOENT") { 39 | // if there is no file, ignore and send 'no file' 40 | connection.send("Not found"); 41 | return; 42 | } 43 | throw err; 44 | }); 45 | 46 | connection.on("close", () => { 47 | watcher.stop(); 48 | }); 49 | } 50 | 51 | close() { 52 | this._server.closeAllConnections(); 53 | } 54 | } 55 | 56 | module.exports = MarkdownSocket; 57 | -------------------------------------------------------------------------------- /src/markdown-watcher.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const convert = require("./markdown"); 4 | const Watcher = require("./watcher"); 5 | 6 | class MarkdownWatcher extends Watcher { 7 | onData(callback) { 8 | this._dataCallback = data => convert(data.toString()).then(callback); 9 | return this; 10 | } 11 | } 12 | 13 | module.exports = MarkdownWatcher; 14 | -------------------------------------------------------------------------------- /src/markdown.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const argv = require("./argv"); 4 | const mdit = require("markdown-it"); 5 | const pandoc = require("simple-pandoc"); 6 | 7 | const singleton = creator => { 8 | let obj; 9 | return () => obj || (obj = creator()); 10 | }; 11 | 12 | const md = singleton(() => 13 | mdit({ html: true, linkify: true }) 14 | .use(require("markdown-it-highlightjs")) 15 | .use(require("markdown-it-emoji")) 16 | .use(require("markdown-it-checkbox")) 17 | .use(require("markdown-it-anchor")) 18 | ); 19 | 20 | const pd = singleton(() => pandoc(argv.pandoc, "html")); 21 | 22 | module.exports = markdown => 23 | argv.pandoc ? pd()(markdown) : Promise.resolve(md().render(markdown)); 24 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | const http = require("http"); 5 | const path = require("path"); 6 | const urllib = require("url"); 7 | const MarkdownSocket = require("./markdown-socket"); 8 | 9 | function isMarkdown(path) { 10 | const lowerCasedPath = path.toLowerCase(); 11 | return lowerCasedPath.endsWith(".md") || lowerCasedPath.endsWith(".markdown"); 12 | } 13 | 14 | class Server { 15 | constructor(rootPath) { 16 | this.rootPath = rootPath; 17 | this._server = http.createServer(this.handler.bind(this)); 18 | 19 | this._ws = new MarkdownSocket(this.rootPath); 20 | this._ws.listenTo(this._server); 21 | } 22 | 23 | listen(port, cb) { 24 | this._server.listen(port, cb); 25 | } 26 | 27 | close(cb) { 28 | this._ws.close(); 29 | this._server.close(cb); 30 | } 31 | 32 | handler(req, res) { 33 | const url = urllib.parse(req.url); 34 | 35 | if (isMarkdown(url.pathname)) { 36 | this.handleAsMarkdown(res); 37 | } else { 38 | this.handleAsStatic(url.pathname, res); 39 | } 40 | } 41 | 42 | handleAsMarkdown(res) { 43 | res.setHeader("Content-Type", "text/html"); 44 | const indexHTMLPath = path.join(__dirname, "../dist/index.html"); 45 | fs.createReadStream(indexHTMLPath).pipe(res); 46 | } 47 | 48 | handleAsStatic(pathname, res) { 49 | const fullPath = path.join(this.rootPath, pathname); 50 | 51 | try { 52 | const stat = fs.statSync(fullPath); 53 | if (stat.isDirectory()) { 54 | if (!pathname.endsWith("/")) { 55 | res.writeHead(302, { Location: pathname + "/" }); 56 | res.end(); 57 | return; 58 | } 59 | 60 | const fileList = fs.readdirSync(fullPath).filter(isMarkdown); 61 | res.setHeader("Content-Type", "text/html"); 62 | res.end(fileList.map(f => `${f}`).join(" ")); 63 | } else { 64 | fs.createReadStream(fullPath).pipe(res); 65 | } 66 | } catch (err) { 67 | if (err.code === "ENOENT") { 68 | res.statusCode = 404; 69 | res.end("Not found"); 70 | } else { 71 | throw err; 72 | } 73 | } 74 | } 75 | } 76 | 77 | module.exports = Server; 78 | -------------------------------------------------------------------------------- /src/watcher.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | 5 | const WatchInterval = 200; // milliseconds 6 | 7 | class Watcher { 8 | constructor(p) { 9 | this.path = p; 10 | this._watchLoop = null; 11 | this._dataCallback = null; 12 | this._errorCallback = null; 13 | this._previousData = null; 14 | 15 | this.start(); 16 | } 17 | 18 | start() { 19 | if (this._watchLoop) { 20 | clearInterval(this._watchLoop); 21 | } 22 | 23 | setTimeout(() => this.watch(), 0); // for the first execution 24 | this._watchLoop = setInterval(() => this.watch(), WatchInterval); 25 | } 26 | 27 | stop() { 28 | clearInterval(this._watchLoop); 29 | this._watchLoop = null; 30 | } 31 | 32 | watch() { 33 | fs.readFile(this.path, (error, data) => { 34 | if (error) { 35 | this.triggerOnError(error); 36 | this.stop(); 37 | } else { 38 | if (!this._previousData || data.compare(this._previousData) !== 0) { 39 | this.triggerOnData(data); 40 | this._previousData = data; 41 | } 42 | } 43 | }); 44 | } 45 | 46 | onData(callback) { 47 | this._dataCallback = callback; 48 | return this; 49 | } 50 | 51 | onError(callback) { 52 | this._errorCallback = callback; 53 | return this; 54 | } 55 | 56 | triggerOnData(data) { 57 | if (this._dataCallback) { 58 | this._dataCallback(data); 59 | } 60 | } 61 | 62 | triggerOnError(error) { 63 | if (this._errorCallback) { 64 | this._errorCallback(error); 65 | } else { 66 | throw error; 67 | } 68 | } 69 | } 70 | 71 | module.exports = Watcher; 72 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | sourceType: module 3 | extends: 4 | - "eslint:recommended" 5 | - "plugin:react/recommended" 6 | rules: 7 | no-console: 1 8 | 9 | env: 10 | mocha: true 11 | -------------------------------------------------------------------------------- /test/lib/helper.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import rimraf from "rimraf"; 4 | 5 | const root = path.join(__dirname, "../temp"); 6 | 7 | function filePath(p) { 8 | return path.join(root, p); 9 | } 10 | 11 | const helper = { 12 | createFile(p, initialContent) { 13 | fs.writeFileSync(filePath(p), initialContent); 14 | }, 15 | makeDirectory(p) { 16 | try { 17 | fs.mkdirSync(filePath(p)); 18 | } catch (e) { 19 | if (e.code !== "EEXIST") { 20 | throw e; 21 | } 22 | } 23 | }, 24 | path(p) { 25 | return path.join(root, p); 26 | }, 27 | createRootDirectory() { 28 | try { 29 | fs.mkdirSync(root); 30 | } catch (e) { 31 | // do nothing 32 | } 33 | }, 34 | clean() { 35 | rimraf.sync(path.join(root, "*")); 36 | } 37 | }; 38 | 39 | helper.createRootDirectory(); 40 | helper.clean(); 41 | 42 | export default helper; 43 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import jsdom from "jsdom"; 2 | 3 | // test setup for browser mocking 4 | global.document = jsdom.jsdom(""); 5 | global.window = global.document.defaultView; 6 | global.navigator = { userAgent: "node.js" }; 7 | -------------------------------------------------------------------------------- /test/test-build-html.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import path from "path"; 3 | import fs from "fs"; 4 | 5 | describe("built HTML", () => { 6 | const indexHTMLPath = path.join(__dirname, "../dist/index.html"); 7 | 8 | it("exists", () => { 9 | assert.ok(fs.readFileSync(indexHTMLPath)); 10 | }); 11 | 12 | it("contains a style tag", () => { 13 | const html = fs.readFileSync(indexHTMLPath); 14 | assert.ok(//.test(html)); 15 | }); 16 | 17 | it("contains a script tag", () => { 18 | const html = fs.readFileSync(indexHTMLPath); 19 | assert.ok(//.test(html)); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/test-html-renderer.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import fs from "fs"; 3 | import helper from "./lib/helper"; 4 | import HTMLRenderer from "../src/frontend/html-renderer"; 5 | import http from "http"; 6 | import MarkdownSocket from "../src/markdown-socket"; 7 | import React from "react"; 8 | import ReactTestUtils from "react-dom/test-utils"; 9 | 10 | function getRenderedHTML(rendered) { 11 | const div = ReactTestUtils.findRenderedDOMComponentWithTag(rendered, "div"); 12 | return div.innerHTML.replace(/ data-react[-\w]+="[^"]+"/g, ""); 13 | } 14 | 15 | describe("HTMLRenderer", () => { 16 | let server; 17 | let mdSocket; 18 | 19 | beforeEach(done => { 20 | helper.makeDirectory("md-root"); 21 | helper.createFile("md-root/test.md", "# hello"); 22 | server = http.createServer((req, res) => res.end("hello")); 23 | mdSocket = new MarkdownSocket(helper.path("md-root")); 24 | mdSocket.listenTo(server); 25 | server.listen(1234, done); 26 | }); 27 | 28 | afterEach(done => { 29 | helper.clean(); 30 | mdSocket.close(); 31 | server.close(done); 32 | }); 33 | 34 | it("renders HTML parsed from Markdown with using Virtual DOM", done => { 35 | let rendered; 36 | let renderer = React.createElement(HTMLRenderer, { 37 | location: { 38 | host: "localhost:1234", 39 | pathname: "/test.md" 40 | }, 41 | onUpdate() { 42 | assert.equal(getRenderedHTML(rendered), '

hello

\n'); 43 | done(); 44 | } 45 | }); 46 | rendered = ReactTestUtils.renderIntoDocument(renderer); 47 | }); 48 | 49 | it("re-renders whenever the file is updated", done => { 50 | const callback = err => { 51 | if (err) { 52 | done(err); 53 | } 54 | }; 55 | 56 | let called = 0; 57 | let rendered; 58 | let renderer = React.createElement(HTMLRenderer, { 59 | location: { 60 | host: "localhost:1234", 61 | pathname: "/test.md" 62 | }, 63 | onUpdate() { 64 | let html = getRenderedHTML(rendered); 65 | switch (called) { 66 | case 0: 67 | assert.equal(html, '

hello

\n'); 68 | fs.writeFile( 69 | helper.path("md-root/test.md"), 70 | "```js\nvar a=10;\n```", 71 | callback 72 | ); 73 | break; 74 | case 1: 75 | assert.equal( 76 | html, 77 | '
var a=10;\n
\n' 78 | ); 79 | fs.writeFile( 80 | helper.path("md-root/test.md"), 81 | "* nested\n * nnested\n * nnnested", 82 | callback 83 | ); 84 | break; 85 | case 2: 86 | assert.equal( 87 | html, 88 | "
    \n
  • nested\n
      \n
    • nnested\n
        \n
      • nnnested
      • \n
      \n
    • \n
    \n
  • \n
\n" 89 | ); 90 | done(); 91 | break; 92 | } 93 | called += 1; 94 | } 95 | }); 96 | rendered = ReactTestUtils.renderIntoDocument(renderer); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/test-index.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import helper from "./lib/helper"; 3 | import path from "path"; 4 | import request from "request"; 5 | import { spawn } from "child_process"; 6 | 7 | describe("index", () => { 8 | let proc; 9 | const cwd = process.cwd(); 10 | const indexScriptPath = path.join(cwd, "index.js"); 11 | 12 | beforeEach(() => { 13 | helper.makeDirectory("server-root"); 14 | helper.createFile("server-root/test1.txt", "hello"); 15 | process.chdir(helper.path("server-root")); 16 | }); 17 | 18 | afterEach(done => { 19 | proc.on("close", done); 20 | proc.kill(); 21 | helper.clean(); 22 | process.chdir(cwd); 23 | }); 24 | 25 | it("runs a server listening to a port", done => { 26 | proc = spawn("node", [indexScriptPath]); 27 | proc.stdout.on("data", data => { 28 | assert.equal(data.toString(), "listening 6060 ...\n"); 29 | request.get("http://localhost:6060/test1.txt", (err, res, body) => { 30 | if (err) { 31 | done(err); 32 | return; 33 | } 34 | 35 | assert.equal(res.statusCode, 200); 36 | assert.equal(body, "hello"); 37 | done(); 38 | }); 39 | }); 40 | }); 41 | 42 | it("runs a server listening to a custom port", done => { 43 | proc = spawn("node", [indexScriptPath, "-p", "1234"]); 44 | proc.stdout.on("data", data => { 45 | assert.equal(data.toString(), "listening 1234 ...\n"); 46 | request.get("http://localhost:1234/test1.txt", (err, res, body) => { 47 | if (err) { 48 | done(err); 49 | return; 50 | } 51 | 52 | assert.equal(res.statusCode, 200); 53 | assert.equal(body, "hello"); 54 | done(); 55 | }); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/test-markdown-socket.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import fs from "fs"; 3 | import helper from "./lib/helper"; 4 | import http from "http"; 5 | import MarkdownSocket from "../src/markdown-socket"; 6 | import { w3cwebsocket as WebSocket } from "websocket"; 7 | 8 | describe("MarkdownSocket", () => { 9 | let server; 10 | let mdSocket; 11 | 12 | beforeEach(done => { 13 | helper.makeDirectory("md-root"); 14 | helper.createFile("md-root/test.md", "# hello"); 15 | server = http.createServer((req, res) => { 16 | res.end("hello"); 17 | }); 18 | mdSocket = new MarkdownSocket(helper.path("md-root")); 19 | mdSocket.listenTo(server); 20 | server.listen(1234, done); 21 | }); 22 | 23 | afterEach(done => { 24 | helper.clean(); 25 | mdSocket.close(); 26 | server.close(done); 27 | }); 28 | 29 | it("handles a websocket connection", done => { 30 | let client = new WebSocket("ws://localhost:1234/test.md"); 31 | 32 | client.onopen = () => { 33 | done(); 34 | }; 35 | }); 36 | 37 | it("cannot handle a non markdown connection", done => { 38 | let client = new WebSocket("ws://localhost:1234"); 39 | 40 | client.onerror = () => { 41 | done(); 42 | }; 43 | }); 44 | 45 | it("opens a Markdown file and sends the parsed HTML", done => { 46 | let client = new WebSocket("ws://localhost:1234/test.md"); 47 | 48 | client.onmessage = message => { 49 | assert.equal(message.data, '

hello

\n'); 50 | done(); 51 | }; 52 | }); 53 | 54 | it("sends parsed HTML data again when the file is updated", done => { 55 | const callback = err => { 56 | if (err) { 57 | done(err); 58 | } 59 | }; 60 | 61 | let called = 0; 62 | let client = new WebSocket("ws://localhost:1234/test.md"); 63 | client.onmessage = message => { 64 | switch (called) { 65 | case 0: 66 | assert.equal(message.data, '

hello

\n'); 67 | fs.writeFile( 68 | helper.path("md-root/test.md"), 69 | "```js\nvar a=10;\n```", 70 | callback 71 | ); 72 | break; 73 | case 1: 74 | assert.equal( 75 | message.data, 76 | '
var a=10;\n
\n' 77 | ); 78 | fs.writeFile( 79 | helper.path("md-root/test.md"), 80 | "* nested\n * nnested\n * nnnested", 81 | callback 82 | ); 83 | break; 84 | case 2: 85 | assert.equal( 86 | message.data, 87 | "
    \n
  • nested\n
      \n
    • nnested\n
        \n
      • nnnested
      • \n
      \n
    • \n
    \n
  • \n
\n" 88 | ); 89 | done(); 90 | break; 91 | } 92 | called += 1; 93 | }; 94 | }); 95 | 96 | it("ignores when there is no file for the path", done => { 97 | let client = new WebSocket("ws://localhost:1234/no-file.md"); 98 | client.onmessage = message => { 99 | assert.equal(message.data, "Not found"); 100 | done(); 101 | }; 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/test-markdown-watcher.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import fs from "fs"; 3 | import helper from "./lib/helper"; 4 | import MarkdownWatcher from "../src/markdown-watcher"; 5 | 6 | describe("MarkdownWatcher", () => { 7 | let watcher; 8 | 9 | beforeEach(() => { 10 | helper.createFile("watcher-temp.md", "# hello"); 11 | }); 12 | 13 | afterEach(() => { 14 | watcher.stop(); 15 | helper.clean(); 16 | }); 17 | 18 | it("reads a Markdown file and send parsed HTML data", done => { 19 | watcher = new MarkdownWatcher(helper.path("watcher-temp.md")); 20 | watcher 21 | .onData(data => { 22 | assert.equal(data, '

hello

\n'); 23 | done(); 24 | }) 25 | .onError(done); 26 | }); 27 | 28 | it("send parsed HTML data again when the file is updated", done => { 29 | const callback = err => { 30 | if (err) { 31 | done(err); 32 | } 33 | }; 34 | 35 | let called = 0; 36 | watcher = new MarkdownWatcher(helper.path("watcher-temp.md")); 37 | watcher 38 | .onData(data => { 39 | switch (called) { 40 | case 0: 41 | assert.equal(data, '

hello

\n'); 42 | fs.writeFile( 43 | helper.path("watcher-temp.md"), 44 | "```js\nvar a=10;\n```", 45 | callback 46 | ); 47 | break; 48 | case 1: 49 | assert.equal( 50 | data, 51 | '
var a=10;\n
\n' 52 | ); 53 | fs.writeFile( 54 | helper.path("watcher-temp.md"), 55 | "* nested\n * nnested\n * nnnested", 56 | callback 57 | ); 58 | break; 59 | case 2: 60 | assert.equal( 61 | data, 62 | "
    \n
  • nested\n
      \n
    • nnested\n
        \n
      • nnnested
      • \n
      \n
    • \n
    \n
  • \n
\n" 63 | ); 64 | done(); 65 | break; 66 | } 67 | called += 1; 68 | }) 69 | .onError(done); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/test-server.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import helper from "./lib/helper"; 3 | import request from "request"; 4 | import Server from "../src/server"; 5 | import { w3cwebsocket as WebSocket } from "websocket"; 6 | 7 | const TestPort = 1234; 8 | 9 | describe("Server", () => { 10 | let server; 11 | 12 | beforeEach(() => { 13 | helper.makeDirectory("server-root"); 14 | helper.createFile("server-root/test1.txt", "hello"); 15 | helper.createFile("server-root/test2.txt", "world"); 16 | helper.createFile("server-root/test.md", "# hello"); 17 | helper.createFile("server-root/test2.md", "# hello"); 18 | helper.createFile("server-root/test3.MD", "# hello"); 19 | helper.createFile("server-root/test4.markdown", "# hello"); 20 | }); 21 | 22 | afterEach(() => { 23 | server.close(); 24 | helper.clean(); 25 | }); 26 | 27 | it("creates a file server on a given path", done => { 28 | server = new Server(helper.path("server-root")); 29 | server.listen(TestPort); 30 | 31 | let url = `http://localhost:${TestPort}/test1.txt`; 32 | request.get(url, (err, res, body) => { 33 | if (err) { 34 | done(err); 35 | return; 36 | } 37 | 38 | assert.equal(res.statusCode, 200); 39 | assert.equal(body, "hello"); 40 | 41 | let url = `http://localhost:${TestPort}/test2.txt`; 42 | request.get(url, (err, res, body) => { 43 | if (err) { 44 | done(err); 45 | return; 46 | } 47 | 48 | assert.equal(res.statusCode, 200); 49 | assert.equal(body, "world"); 50 | done(); 51 | }); 52 | }); 53 | }); 54 | 55 | it("fails when there is no file", done => { 56 | server = new Server(helper.path("server-root")); 57 | server.listen(TestPort); 58 | 59 | let url = `http://localhost:${TestPort}/test3.txt`; 60 | request.get(url, (err, res, body) => { 61 | if (err) { 62 | done(err); 63 | return; 64 | } 65 | 66 | assert.equal(res.statusCode, 404); 67 | assert.equal(body, "Not found"); 68 | done(); 69 | }); 70 | }); 71 | 72 | it("shows a list of Markdown files for directories", done => { 73 | server = new Server(helper.path("server-root")); 74 | server.listen(TestPort); 75 | 76 | let url = `http://localhost:${TestPort}/`; 77 | request.get(url, (err, res, body) => { 78 | if (err) { 79 | done(err); 80 | return; 81 | } 82 | 83 | assert.equal(res.statusCode, 200); 84 | assert.equal( 85 | body, 86 | "test.md test2.md test3.MD test4.markdown" 87 | ); 88 | done(); 89 | }); 90 | }); 91 | 92 | function previewTest(filename) { 93 | return new Promise((resolve, reject) => { 94 | request.get( 95 | `http://localhost:${TestPort}/${filename}`, 96 | (err, res, body) => { 97 | if (err) { 98 | reject(err); 99 | return; 100 | } 101 | 102 | assert.equal(res.statusCode, 200); 103 | assert.equal(res.headers["content-type"], "text/html"); 104 | assert.ok(//.test(body)); 105 | assert.ok(//.test(body)); 106 | resolve(); 107 | } 108 | ); 109 | }); 110 | } 111 | 112 | it("shows a preview page for Markdown files", async () => { 113 | server = new Server(helper.path("server-root")); 114 | server.listen(TestPort); 115 | 116 | await previewTest("test.md"); 117 | await previewTest("test2.md"); 118 | await previewTest("test3.MD"); 119 | await previewTest("test4.markdown"); 120 | }); 121 | 122 | it("receives a websocket connection", done => { 123 | server = new Server(helper.path("server-root")); 124 | server.listen(TestPort); 125 | 126 | let url = `ws://localhost:${TestPort}/test.md`; 127 | let ws = new WebSocket(url); 128 | ws.onmessage = message => { 129 | assert.equal(message.data, '

hello

\n'); 130 | done(); 131 | }; 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /test/test-socket-client.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import fs from "fs"; 3 | import helper from "./lib/helper"; 4 | import http from "http"; 5 | import MarkdownSocket from "../src/markdown-socket"; 6 | import SocketClient from "../src/frontend/socket-client"; 7 | 8 | describe("SocketClient", () => { 9 | let server; 10 | let mdSocket; 11 | 12 | beforeEach(done => { 13 | helper.makeDirectory("md-root"); 14 | helper.createFile("md-root/test.md", "# hello"); 15 | server = http.createServer((req, res) => res.end("hello")); 16 | mdSocket = new MarkdownSocket(helper.path("md-root")); 17 | mdSocket.listenTo(server); 18 | server.listen(1234, done); 19 | }); 20 | 21 | afterEach(done => { 22 | helper.clean(); 23 | mdSocket.close(); 24 | server.close(done); 25 | }); 26 | 27 | it("receives HTML data sent from a Markdown socket server", done => { 28 | let client = new SocketClient({ 29 | host: "localhost:1234", 30 | pathname: "/test.md" 31 | }); 32 | client.onData(html => { 33 | assert.equal(html, '

hello

\n'); 34 | done(); 35 | }); 36 | }); 37 | 38 | it("receives the data whenever the file is updated", done => { 39 | const callback = err => { 40 | if (err) { 41 | done(err); 42 | } 43 | }; 44 | 45 | let called = 0; 46 | let client = new SocketClient({ 47 | host: "localhost:1234", 48 | pathname: "/test.md" 49 | }); 50 | client.onData(html => { 51 | switch (called) { 52 | case 0: 53 | assert.equal(html, '

hello

\n'); 54 | fs.writeFile( 55 | helper.path("md-root/test.md"), 56 | "```js\nvar a=10;\n```", 57 | callback 58 | ); 59 | break; 60 | case 1: 61 | assert.equal( 62 | html, 63 | '
var a=10;\n
\n' 64 | ); 65 | fs.writeFile( 66 | helper.path("md-root/test.md"), 67 | "* nested\n * nnested\n * nnnested", 68 | callback 69 | ); 70 | break; 71 | case 2: 72 | assert.equal( 73 | html, 74 | "
    \n
  • nested\n
      \n
    • nnested\n
        \n
      • nnnested
      • \n
      \n
    • \n
    \n
  • \n
\n" 75 | ); 76 | done(); 77 | break; 78 | } 79 | called += 1; 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/test-watcher.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import fs from "fs"; 3 | import helper from "./lib/helper"; 4 | import Watcher from "../src/watcher"; 5 | 6 | describe("Watcher", () => { 7 | let watcher; 8 | 9 | beforeEach(() => { 10 | helper.createFile("test테스트テスト.txt", "hello"); 11 | }); 12 | 13 | afterEach(() => { 14 | watcher.stop(); 15 | helper.clean(); 16 | }); 17 | 18 | it("reads a file", done => { 19 | watcher = new Watcher(helper.path("test테스트テスト.txt")); 20 | watcher 21 | .onData(data => { 22 | assert.equal(data.toString(), "hello"); 23 | done(); 24 | }) 25 | .onError(done); 26 | }); 27 | 28 | it("cannot read a wrong file", done => { 29 | let watcher = new Watcher(helper.path("watcher-wrong-temp")); 30 | watcher 31 | .onData(() => { 32 | done("there shouldn't be a file!"); 33 | }) 34 | .onError(error => { 35 | assert.equal(error.code, "ENOENT"); 36 | done(); 37 | }); 38 | }); 39 | 40 | it("send the data again when the file is updated", done => { 41 | const callback = err => { 42 | if (err) { 43 | done(err); 44 | } 45 | }; 46 | 47 | let called = 0; 48 | watcher = new Watcher(helper.path("test테스트テスト.txt")); 49 | watcher 50 | .onData(data => { 51 | switch (called) { 52 | case 0: 53 | assert.equal(data.toString(), "hello"); 54 | fs.writeFile( 55 | helper.path("test테스트テスト.txt"), 56 | "world", 57 | callback 58 | ); 59 | break; 60 | case 1: 61 | assert.equal(data.toString(), "world"); 62 | fs.writeFile(helper.path("test테스트テスト.txt"), "pen!", callback); 63 | break; 64 | case 2: 65 | assert.equal(data.toString(), "pen!"); 66 | done(); 67 | break; 68 | } 69 | called += 1; 70 | }) 71 | .onError(done); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 3 | const failPlugin = require("webpack-fail-plugin"); 4 | const HTMLInlineSourcePlugin = require("html-webpack-inline-source-plugin"); 5 | const HTMLPlugin = require("html-webpack-plugin"); 6 | const path = require("path"); 7 | const webpack = require("webpack"); 8 | 9 | // Always enabled plugins 10 | const plugins = [ 11 | // Extract CSS files to the 'bundle.css'. 12 | new ExtractTextPlugin("build.css"), 13 | new HTMLPlugin({ 14 | title: "Pen", 15 | inlineSource: ".(js|css)$" 16 | }), 17 | new HTMLInlineSourcePlugin(), 18 | // This plugin should be always required. See https://github.com/webpack/webpack/issues/708 19 | failPlugin 20 | ]; 21 | 22 | // Production only plugins 23 | if (process.env.NODE_ENV === "production") { 24 | plugins.push( 25 | // Pass the 'NODE_ENV=production' environment variable to the child processes. 26 | new webpack.DefinePlugin({ 27 | "process.env": { NODE_ENV: JSON.stringify("production") } 28 | }) 29 | ); 30 | } 31 | 32 | // Configs 33 | module.exports = { 34 | entry: "./main.js", 35 | context: path.resolve(__dirname, "src/frontend"), 36 | output: { 37 | filename: "build.js", 38 | path: path.resolve(__dirname, "dist") 39 | }, 40 | module: { 41 | loaders: [ 42 | { 43 | test: /\.css$/, 44 | use: ExtractTextPlugin.extract({ 45 | fallback: "style-loader", 46 | use: "css-loader" 47 | }) 48 | }, 49 | { 50 | test: /\.json$/, 51 | use: "json-loader" 52 | }, 53 | { 54 | test: /\.js$/, 55 | exclude: /node_modules/, 56 | use: "babel-loader" 57 | } 58 | ] 59 | }, 60 | plugins 61 | }; 62 | --------------------------------------------------------------------------------