├── .gitignore ├── .prettierrc.json ├── .vscode └── settings.json ├── README.md ├── __snapshots__ └── e2e.js ├── bin └── term-to-html.js ├── circle.yml ├── e2e.js ├── images ├── html.png └── term.png ├── index.js ├── package-lock.json ├── package.json ├── renovate.json └── spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | mocha.html 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.formatOnPaste": false, 5 | "[json]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "json.format.enable": false 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # term-to-html [![CircleCI](https://circleci.com/gh/bahmutov/term-to-html/tree/master.svg?style=svg)](https://circleci.com/gh/bahmutov/term-to-html/tree/master) 2 | 3 | > Stream terminal output with ansi codes into nicely formatted HTML 4 | 5 | Imagine you have terminal output, let's say from a test runner. It looks like this 6 | 7 | ![terminal output](images/term.png) 8 | 9 | You can pipe the output through `term-to-html` to create equivalent HTML file. Same output (HTML page was white background) 10 | 11 | ![html](images/html.png) 12 | 13 | The output could be used to do [visual testing against CLI output](https://glebbahmutov.com/blog/visual-diffing-for-CLI-apps/) 14 | 15 | ## Use 16 | 17 | ```shell 18 | npm i -g term-to-html 19 | mocha spec.js --reporter spec | term-to-html > mocha.html 20 | ``` 21 | 22 | **Note:** many applications detect non-interactive terminal and turn off colors. Usually you can enable colors using an environment variable, like `FORCE_COLOR=2` in [chalk](https://github.com/chalk/chalk) library. 23 | 24 | ### Dark theme 25 | 26 | You can output HTML page with dark background using `--theme dark` CLI argument 27 | 28 | ```shell 29 | | term-to-html --theme dark 30 | ``` 31 | 32 | ### Use as a module 33 | 34 | ```js 35 | const termToHtml = require('term-to-html') 36 | const html = termToHtml.strings(stringWithAnsi, termToHtml.themes.dark.name) 37 | ``` 38 | 39 | ## Testing 40 | 41 | There are [E2E tests](e2e.js) and you can observe the output by running 42 | 43 | ```shell 44 | FORCE_COLOR=2 npx mocha spec.js --reporter spec | ./bin/term-to-html.js 45 | ``` 46 | 47 | ### Small print 48 | 49 | Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2020 50 | 51 | - [@bahmutov](https://twitter.com/bahmutov) 52 | - [glebbahmutov.com](https://glebbahmutov.com) 53 | - [blog](https://glebbahmutov.com/blog) 54 | 55 | License: MIT - do anything with the code, but don't blame me if it does not work. 56 | 57 | Support: if you find any problems with this module, email / tweet / 58 | [open issue](https://github.com/bahmutov/term-to-html/issues) on Github 59 | 60 | ## MIT License 61 | 62 | Copyright (c) 2020 Gleb Bahmutov <gleb.bahmutov@gmail.com> 63 | 64 | Permission is hereby granted, free of charge, to any person 65 | obtaining a copy of this software and associated documentation 66 | files (the "Software"), to deal in the Software without 67 | restriction, including without limitation the rights to use, 68 | copy, modify, merge, publish, distribute, sublicense, and/or sell 69 | copies of the Software, and to permit persons to whom the 70 | Software is furnished to do so, subject to the following 71 | conditions: 72 | 73 | The above copyright notice and this permission notice shall be 74 | included in all copies or substantial portions of the Software. 75 | 76 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 77 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 78 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 79 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 80 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 81 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 82 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 83 | OTHER DEALINGS IN THE SOFTWARE. 84 | 85 | [npm-icon]: https://nodei.co/npm/term-to-html.svg?downloads=true 86 | [npm-url]: https://npmjs.org/package/term-to-html 87 | [renovate-badge]: https://img.shields.io/badge/renovate-app-blue.svg 88 | [renovate-app]: https://renovateapp.com/ 89 | -------------------------------------------------------------------------------- /__snapshots__/e2e.js: -------------------------------------------------------------------------------- 1 | exports['generated html'] = ` 2 | 3 | 4 | 5 | 6 | 20 | 21 |
 22 | 
 23 |   
 24 | 
 25 | 
 26 |   example
 27 |    works A (1000ms)
 28 |    works B (1000ms)
 29 |    works C (1000ms)
 30 |     - skips D
 31 | hello <there>!
 32 |    has brackets < and >
 33 | ┌────────────────────┬────────────────────┐
 34 |  TH 1 label          TH 2 label         
 35 | ├────────────────────┼────────────────────┤
 36 |  First value         Second value       
 37 | ├────────────────────┼────────────────────┤
 38 |  First value         Second value       
 39 | └────────────────────┴────────────────────┘
 40 |    has a table
 41 | 
 42 | 
 43 |   5 passing (3s)
 44 |   1 pending
 45 | 
 46 | 
 47 | 
48 | ` 49 | 50 | exports['html with dark theme'] = ` 51 | 52 | 53 | 54 | 55 | 69 | 70 |
 71 | 
 72 |   
 73 | 
 74 | 
 75 |   example
 76 |    works A (1000ms)
 77 |    works B (1000ms)
 78 |    works C (1000ms)
 79 |     - skips D
 80 | hello <there>!
 81 |    has brackets < and >
 82 | ┌────────────────────┬────────────────────┐
 83 |  TH 1 label          TH 2 label         
 84 | ├────────────────────┼────────────────────┤
 85 |  First value         Second value       
 86 | ├────────────────────┼────────────────────┤
 87 |  First value         Second value       
 88 | └────────────────────┴────────────────────┘
 89 |    has a table
 90 | 
 91 | 
 92 |   5 passing (3s)
 93 |   1 pending
 94 | 
 95 | 
 96 | 
97 | ` 98 | 99 | exports['string to string dark theme'] = ` 100 | 101 | 102 | 103 | 104 | 118 | 119 |
red cyan
120 | second line
121 | 
122 | ` 123 | 124 | exports['themes'] = { 125 | "light": { 126 | "newline": false, 127 | "bg": "#fff", 128 | "fg": "#111", 129 | "name": "light" 130 | }, 131 | "dark": { 132 | "newline": false, 133 | "bg": "#000", 134 | "fg": "#eee", 135 | "name": "dark" 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /bin/term-to-html.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // generates HTML page from streamed STDOUT 4 | // $ FORCE_COLOR=2 npx mocha spec.js --reporter spec | ./pass.js 5 | // save or redirect the generated HTML file and get yourself a nice page. 6 | 7 | const arg = require('arg') 8 | const args = arg({ 9 | '--theme': String, 10 | }) 11 | 12 | const { streams, themes } = require('..') 13 | 14 | const options = args['--theme'] === 'dark' ? themes.dark : themes.light 15 | 16 | streams(options) 17 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | # Circle Node orb makes it simple to cache dependencies 4 | # https://circleci.com/orbs/registry/orb/circleci/node 5 | node: circleci/node@1.1 6 | jobs: 7 | build: 8 | executor: 9 | name: node/default 10 | tag: '12' 11 | steps: 12 | - checkout 13 | - node/with-cache: 14 | steps: 15 | - run: 16 | name: install dependencies 📦 17 | command: npm ci 18 | - run: 19 | command: FORCE_COLOR=2 npx mocha spec.js --reporter spec | bin/term-to-html.js > mocha.html 20 | name: example page 📊 21 | - store_artifacts: 22 | path: mocha.html 23 | - run: 24 | command: FORCE_COLOR=2 npx mocha spec.js --reporter spec | bin/term-to-html.js --theme dark > mocha-dark.html 25 | name: example dark page 📊 26 | - store_artifacts: 27 | path: mocha-dark.html 28 | - run: 29 | name: run tests 🧪 30 | command: npm run e2e 31 | - run: 32 | name: NPM publish 🚀 33 | command: npm run semantic-release 34 | -------------------------------------------------------------------------------- /e2e.js: -------------------------------------------------------------------------------- 1 | const execa = require('execa') 2 | const snapshot = require('snap-shot-it') 3 | const chalk = require('chalk') 4 | const termToHtml = require('.') 5 | 6 | /** 7 | * Replace Mocha's time duration warnings with same value 8 | * @param {string} s output from Mocha converted to HTML 9 | * @returns {string} sanitized result 10 | */ 11 | const sanitize = s => { 12 | const duration1xxx = /\(10\d\dms\)/g 13 | const duration9xx = /\(9\d\dms\)/g 14 | const value = '(1000ms)' 15 | return s.replace(duration1xxx, value).replace(duration9xx, value) 16 | } 17 | 18 | it('works', () => { 19 | return execa('npx mocha spec.js --reporter spec | bin/term-to-html.js', { 20 | shell: true, 21 | env: { 22 | FORCE_COLOR: 2, 23 | }, 24 | }).then(result => { 25 | const text = sanitize(result.stdout) 26 | snapshot('generated html', text) 27 | }) 28 | }) 29 | 30 | it('supports dark theme', () => { 31 | return execa( 32 | 'npx mocha spec.js --reporter spec | bin/term-to-html.js --theme dark', 33 | { 34 | shell: true, 35 | env: { 36 | FORCE_COLOR: 2, 37 | }, 38 | }, 39 | ).then(result => { 40 | const text = sanitize(result.stdout) 41 | snapshot('html with dark theme', text) 42 | }) 43 | }) 44 | 45 | it('can be used as a module', () => { 46 | const ansi = 47 | chalk.red('red') + ' ' + chalk.cyan('cyan') + '\n' + 'second line' 48 | const html = termToHtml.strings(ansi) 49 | snapshot('string to string dark theme', html) 50 | }) 51 | 52 | it('has themes', () => { 53 | snapshot('themes', termToHtml.themes) 54 | }) 55 | -------------------------------------------------------------------------------- /images/html.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahmutov/term-to-html/ae5ddf0ecd0ba02a44968ab4504eb8d8d2ec5a28/images/html.png -------------------------------------------------------------------------------- /images/term.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahmutov/term-to-html/ae5ddf0ecd0ba02a44968ab4504eb8d8d2ec5a28/images/term.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const AnsiToHtml = require('ansi-to-html') 2 | const escape = require('escape-html') 3 | 4 | const lightTheme = { 5 | newline: false, 6 | bg: '#fff', 7 | fg: '#111', 8 | name: 'light', 9 | } 10 | 11 | const darkTheme = { 12 | newline: false, 13 | bg: '#000', 14 | fg: '#eee', 15 | name: 'dark', 16 | } 17 | 18 | const getHtmlStart = options => { 19 | const start = ` 20 | 21 | 22 | 23 | 37 | 38 |
\n
39 |   `
40 |   return start
41 | }
42 | 
43 | const htmlEnd = '\n
' 44 | 45 | const streams = options => { 46 | const convert = new AnsiToHtml(options) 47 | 48 | const start = getHtmlStart(options) 49 | console.log(start) 50 | 51 | process.stdin.setEncoding('utf8') 52 | process.stdin.on('data', function(chunk) { 53 | return process.stdout.write(convert.toHtml(escape(chunk))) 54 | }) 55 | process.stdin.on('end', () => { 56 | console.log(htmlEnd) 57 | }) 58 | } 59 | 60 | const strings = (s, theme = 'dark') => { 61 | const options = theme === 'dark' ? darkTheme : lightTheme 62 | const htmlStart = getHtmlStart(options) 63 | const convert = new AnsiToHtml(options) 64 | const converted = convert.toHtml(escape(s)) 65 | return htmlStart.trim() + converted + htmlEnd 66 | } 67 | 68 | module.exports = { 69 | streams, 70 | strings, 71 | themes: { 72 | light: lightTheme, 73 | dark: darkTheme, 74 | }, 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "term-to-html", 3 | "version": "0.0.0-development", 4 | "description": "Stream terminal output with ansi codes into nicely formatted HTML", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha spec.js", 8 | "e2e": "mocha --timeout 30000 e2e.js", 9 | "semantic-release": "semantic-release" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/bahmutov/term-to-html.git" 14 | }, 15 | "files": [ 16 | "bin", 17 | "index.js" 18 | ], 19 | "bin": { 20 | "term-to-html": "bin/term-to-html.js" 21 | }, 22 | "keywords": [ 23 | "cli", 24 | "terminal", 25 | "ansi", 26 | "html" 27 | ], 28 | "author": "Gleb Bahmutov ", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/bahmutov/term-to-html/issues" 32 | }, 33 | "homepage": "https://github.com/bahmutov/term-to-html#readme", 34 | "devDependencies": { 35 | "chalk": "4.1.2", 36 | "cli-table3": "0.6.0", 37 | "execa": "5.1.1", 38 | "mocha": "8.4.0", 39 | "prettier": "2.4.1", 40 | "semantic-release": "17.4.7", 41 | "snap-shot-it": "7.9.6" 42 | }, 43 | "dependencies": { 44 | "ansi-to-html": "0.7.2", 45 | "arg": "5.0.1", 46 | "escape-html": "1.0.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true, 6 | "prConcurrentLimit": 3, 7 | "prHourlyLimit": 2, 8 | "rangeStrategy": "pin", 9 | "schedule": [ 10 | "after 10pm and before 5am on every weekday", 11 | "every weekend" 12 | ], 13 | "updateNotScheduled": false, 14 | "timezone": "America/New_York", 15 | "lockFileMaintenance": { 16 | "enabled": true 17 | }, 18 | "separatePatchReleases": true, 19 | "separateMultipleMajor": true, 20 | "masterIssue": true, 21 | "labels": [ 22 | "type: dependencies", 23 | "renovate" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /spec.js: -------------------------------------------------------------------------------- 1 | var Table = require('cli-table3'); 2 | const delay = (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms)) 3 | describe('example', () => { 4 | it('works A', () => delay()) 5 | it('works B', () => delay()) 6 | it('works C', () => delay()) 7 | it('skips D') 8 | it('has brackets < and >', () => { 9 | console.log('hello !') 10 | }) 11 | it('has a table', () => { 12 | // instantiate 13 | var table = new Table({ 14 | head: ['TH 1 label', 'TH 2 label'] 15 | , colWidths: [20, 20] 16 | }); 17 | 18 | // table is an Array, so you can `push`, `unshift`, `splice` and friends 19 | table.push( 20 | ['First value', 'Second value'] 21 | , ['First value', 'Second value'] 22 | ); 23 | 24 | console.log(table.toString()); 25 | }) 26 | }) 27 | --------------------------------------------------------------------------------