├── jest.config.js ├── test-setup.js ├── .github └── workflows │ └── npm-release.yml ├── tsconfig.json ├── .gitignore ├── package.json ├── README.md ├── src └── index.ts └── __tests__ └── index.spec.tsx /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.(ts|tsx)$": "ts-jest" 4 | }, 5 | setupFilesAfterEnv: ["./test-setup.js"] 6 | }; 7 | -------------------------------------------------------------------------------- /test-setup.js: -------------------------------------------------------------------------------- 1 | const enzyme = require("enzyme"); 2 | const Adapter = require("enzyme-adapter-react-16"); 3 | 4 | enzyme.configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /.github/workflows/npm-release.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-16.04 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-node@v1 9 | with: 10 | node-version: '12' 11 | - run: npm ci 12 | - run: npm run build 13 | - run: npm test 14 | - name: Release 15 | env: 16 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 17 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | run: npx semantic-release 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "composite": true, 7 | "lib": ["esnext", "dom"], 8 | "target": "es2017", 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "skipLibCheck": true, 12 | "noImplicitAny": true, 13 | "outDir": "lib", 14 | "rootDir": "src", 15 | "resolveJsonModule": true, 16 | "preserveWatchOutput": true, 17 | "jsx": "react" 18 | }, 19 | "include": ["src"], 20 | "references": [] 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 28 | node_modules 29 | 30 | lib/ 31 | 32 | tsconfig.tsbuildinfo 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ansi-to-react", 3 | "version": "6.0.10", 4 | "description": "ANSI to React Elements", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "nteractDesktop": "src/index.ts", 8 | "scripts": { 9 | "build": "tsc -b", 10 | "test": "jest", 11 | "semantic-release": "semantic-release" 12 | }, 13 | "files": ["lib"], 14 | "repository": "https://github.com/nteract/ansi-to-react", 15 | "keywords": [ 16 | "ansi", 17 | "react" 18 | ], 19 | "publishConfig": { 20 | "access": "public" 21 | }, 22 | "author": "Kyle Kelley ", 23 | "license": "BSD-3-Clause", 24 | "dependencies": { 25 | "anser": "^1.4.1", 26 | "escape-carriage": "^1.3.0" 27 | }, 28 | "peerDependencies": { 29 | "react": "^16.3.2 || ^17.0.0 || ^18.0.0", 30 | "react-dom": "^16.3.2 || ^17.0.0 || ^18.0.0" 31 | }, 32 | "devDependencies": { 33 | "@semantic-release/npm": "^7.0.8", 34 | "@types/enzyme": "^3.10.5", 35 | "@types/jest": "^25.1.4", 36 | "@types/react": "^16.9.23", 37 | "conventional-changelog-conventionalcommits": "^4.5.0", 38 | "enzyme": "^3.11.0", 39 | "enzyme-adapter-react-16": "^1.15.2", 40 | "jest": "^25.1.0", 41 | "react": "^16.13.0", 42 | "react-dom": "^16.13.0", 43 | "semantic-release": "^17.2.1", 44 | "ts-jest": "^25.2.1", 45 | "typescript": "^3.8.3" 46 | }, 47 | "release": { 48 | "plugins": [ 49 | "@semantic-release/npm", 50 | [ 51 | "@semantic-release/commit-analyzer", 52 | { 53 | "preset": "conventionalcommits" 54 | } 55 | ] 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ansi-to-react 2 | 3 | This package convert ANSI escape codes to formatted text output for React. 4 | 5 | ## Installation 6 | 7 | ``` 8 | $ yarn add ansi-to-react 9 | ``` 10 | 11 | ``` 12 | $ npm install --save ansi-to-react 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### Basic 18 | 19 | The example below shows how we can use this package to render a string with ANSI escape codes. 20 | 21 | ```javascript 22 | import Ansi from "ansi-to-react"; 23 | 24 | export function () => { 25 | return 26 | {'\u001b[34mhello world'} 27 | ; 28 | }; 29 | ``` 30 | 31 | Will render: 32 | 33 | ```javascript 34 | 35 | hello world 36 | 37 | ``` 38 | 39 | ### Classes 40 | 41 | Style with classes instead of `style` attribute. 42 | 43 | ```javascript 44 | {"\u001b[34mhello world"} 45 | ``` 46 | 47 | Will render 48 | 49 | ```javascript 50 | 51 | hello world 52 | 53 | ``` 54 | 55 | #### Class Names 56 | 57 | | Font color | Background Color | 58 | | ---------------------- | ---------------- | 59 | | ansi-black-fg | ansi-black-bg | 60 | | ansi-red-fg | ansi-red-bg | 61 | | ansi-green-fg | ansi-green-bg | 62 | | ansi-yellow-fg | ansi-yellow-bg | 63 | | ansi-blue-fg | ansi-blue-bg | 64 | | ansi-magenta-fg | ansi-magenta-bg | 65 | | ansi-cyan-fg | ansi-cyan-bg | 66 | | ansi-white-fg | ansi-white-bg | 67 | | ansi-bright-black-fg | 68 | | ansi-bright-red-fg | 69 | | ansi-bright-green-fg | 70 | | ansi-bright-yellow-fg | 71 | | ansi-bright-blue-fg | 72 | | ansi-bright-magenta-fg | 73 | | ansi-bright-cyan-fg | 74 | | ansi-bright-white-fg | 75 | 76 | ## Development 77 | 78 | To develop on this project, fork and clone this repository on your local machine. Before making modifications, install the project's dependencies. 79 | 80 | ``` 81 | $ npm install 82 | ``` 83 | 84 | To run the test suite for this project, run: 85 | 86 | ``` 87 | $ npm test 88 | ``` 89 | 90 | ## Documentation 91 | 92 | We're working on adding more documentation for this component. Stay tuned by watching this repository! 93 | 94 | ## Support 95 | 96 | If you experience an issue while using this package or have a feature request, please file an issue on the [issue board](https://github.com/nteract/ansi-to-react/issues), 97 | 98 | ## License 99 | 100 | [BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/) 101 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Anser, { AnserJsonEntry } from "anser"; 2 | import { escapeCarriageReturn } from "escape-carriage"; 3 | import * as React from "react"; 4 | 5 | /** 6 | * Converts ANSI strings into JSON output. 7 | * @name ansiToJSON 8 | * @function 9 | * @param {String} input The input string. 10 | * @param {boolean} use_classes If `true`, HTML classes will be appended 11 | * to the HTML output. 12 | * @return {Array} The parsed input. 13 | */ 14 | function ansiToJSON( 15 | input: string, 16 | use_classes: boolean = false 17 | ): AnserJsonEntry[] { 18 | input = escapeCarriageReturn(fixBackspace(input)); 19 | return Anser.ansiToJson(input, { 20 | json: true, 21 | remove_empty: true, 22 | use_classes, 23 | }); 24 | } 25 | 26 | /** 27 | * Create a class string. 28 | * @name createClass 29 | * @function 30 | * @param {AnserJsonEntry} bundle 31 | * @return {String} class name(s) 32 | */ 33 | function createClass(bundle: AnserJsonEntry): string | null { 34 | let classNames: string = ""; 35 | 36 | if (bundle.bg) { 37 | classNames += `${bundle.bg}-bg `; 38 | } 39 | if (bundle.fg) { 40 | classNames += `${bundle.fg}-fg `; 41 | } 42 | if (bundle.decoration) { 43 | classNames += `ansi-${bundle.decoration} `; 44 | } 45 | 46 | if (classNames === "") { 47 | return null; 48 | } 49 | 50 | classNames = classNames.substring(0, classNames.length - 1); 51 | return classNames; 52 | } 53 | 54 | /** 55 | * Create the style attribute. 56 | * @name createStyle 57 | * @function 58 | * @param {AnserJsonEntry} bundle 59 | * @return {Object} returns the style object 60 | */ 61 | function createStyle(bundle: AnserJsonEntry): React.CSSProperties { 62 | const style: React.CSSProperties = {}; 63 | if (bundle.bg) { 64 | style.backgroundColor = `rgb(${bundle.bg})`; 65 | } 66 | if (bundle.fg) { 67 | style.color = `rgb(${bundle.fg})`; 68 | } 69 | switch (bundle.decoration) { 70 | case 'bold': 71 | style.fontWeight = 'bold'; 72 | break; 73 | case 'dim': 74 | style.opacity = '0.5'; 75 | break; 76 | case 'italic': 77 | style.fontStyle = 'italic'; 78 | break; 79 | case 'hidden': 80 | style.visibility = 'hidden'; 81 | break; 82 | case 'strikethrough': 83 | style.textDecoration = 'line-through'; 84 | break; 85 | case 'underline': 86 | style.textDecoration = 'underline'; 87 | break; 88 | case 'blink': 89 | style.textDecoration = 'blink'; 90 | break; 91 | default: 92 | break; 93 | } 94 | return style; 95 | } 96 | 97 | /** 98 | * Converts an Anser bundle into a React Node. 99 | * @param linkify whether links should be converting into clickable anchor tags. 100 | * @param useClasses should render the span with a class instead of style. 101 | * @param bundle Anser output. 102 | * @param key 103 | */ 104 | 105 | function convertBundleIntoReact( 106 | linkify: boolean, 107 | useClasses: boolean, 108 | bundle: AnserJsonEntry, 109 | key: number 110 | ): JSX.Element { 111 | const style = useClasses ? null : createStyle(bundle); 112 | const className = useClasses ? createClass(bundle) : null; 113 | 114 | if (!linkify) { 115 | return React.createElement( 116 | "span", 117 | { style, key, className }, 118 | bundle.content 119 | ); 120 | } 121 | 122 | const content: React.ReactNode[] = []; 123 | const linkRegex = /(\s|^)(https?:\/\/(?:www\.|(?!www))[^\s.]+\.[^\s]{2,}|www\.[^\s]+\.[^\s]{2,})/g; 124 | 125 | let index = 0; 126 | let match: RegExpExecArray | null; 127 | while ((match = linkRegex.exec(bundle.content)) !== null) { 128 | const [, pre, url] = match; 129 | 130 | const startIndex = match.index + pre.length; 131 | if (startIndex > index) { 132 | content.push(bundle.content.substring(index, startIndex)); 133 | } 134 | 135 | // Make sure the href we generate from the link is fully qualified. We assume http 136 | // if it starts with a www because many sites don't support https 137 | const href = url.startsWith("www.") ? `http://${url}` : url; 138 | content.push( 139 | React.createElement( 140 | "a", 141 | { 142 | key: index, 143 | href, 144 | target: "_blank", 145 | }, 146 | `${url}` 147 | ) 148 | ); 149 | 150 | index = linkRegex.lastIndex; 151 | } 152 | 153 | if (index < bundle.content.length) { 154 | content.push(bundle.content.substring(index)); 155 | } 156 | 157 | return React.createElement("span", { style, key, className }, content); 158 | } 159 | 160 | declare interface Props { 161 | children?: string; 162 | linkify?: boolean; 163 | className?: string; 164 | useClasses?: boolean; 165 | } 166 | 167 | export default function Ansi(props: Props): JSX.Element { 168 | const { className, useClasses, children, linkify } = props; 169 | return React.createElement( 170 | "code", 171 | { className }, 172 | ansiToJSON(children ?? "", useClasses ?? false).map( 173 | convertBundleIntoReact.bind(null, linkify ?? false, useClasses ?? false) 174 | ) 175 | ); 176 | } 177 | 178 | // This is copied from the Jupyter Classic source code 179 | // notebook/static/base/js/utils.js to handle \b in a way 180 | // that is **compatible with Jupyter classic**. One can 181 | // argue that this behavior is questionable: 182 | // https://stackoverflow.com/questions/55440152/multiple-b-doesnt-work-as-expected-in-jupyter# 183 | function fixBackspace(txt: string) { 184 | let tmp = txt; 185 | do { 186 | txt = tmp; 187 | // Cancel out anything-but-newline followed by backspace 188 | tmp = txt.replace(/[^\n]\x08/gm, ""); 189 | } while (tmp.length < txt.length); 190 | return txt; 191 | } 192 | -------------------------------------------------------------------------------- /__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "enzyme"; 2 | import React from "react"; 3 | 4 | import Ansi from "../src/index"; 5 | 6 | const GREEN_FG = "\u001b[32m"; 7 | const YELLOW_BG = "\u001b[43m"; 8 | const BOLD = "\u001b[1m"; 9 | const RESET = "\u001b[0;m"; 10 | 11 | describe("Ansi", () => { 12 | test("hello world", () => { 13 | const el = shallow(React.createElement(Ansi, null, "hello world")); 14 | expect(el).not.toBeNull(); 15 | expect(el.text()).toBe("hello world"); 16 | }); 17 | 18 | test("can color", () => { 19 | const el = shallow( 20 | React.createElement(Ansi, null, `hello ${GREEN_FG}world`) 21 | ); 22 | expect(el).not.toBeNull(); 23 | expect(el.text()).toBe("hello world"); 24 | expect(el.html()).toBe( 25 | 'hello world' 26 | ); 27 | }); 28 | 29 | test("can have className", () => { 30 | const el = shallow( 31 | React.createElement(Ansi, { className: "my-class" }, "hello world") 32 | ); 33 | expect(el).not.toBeNull(); 34 | expect(el.text()).toBe("hello world"); 35 | expect(el.html()).toBe( 36 | 'hello world' 37 | ); 38 | }); 39 | 40 | test("can nest", () => { 41 | const el = shallow( 42 | React.createElement( 43 | Ansi, 44 | null, 45 | `hello ${GREEN_FG}wo${YELLOW_BG}rl${RESET}d` 46 | ) 47 | ); 48 | expect(el).not.toBeNull(); 49 | expect(el.text()).toBe("hello world"); 50 | expect(el.html()).toBe( 51 | 'hello world' 52 | ); 53 | }); 54 | 55 | test("can handle carriage symbol", () => { 56 | const el = shallow( 57 | React.createElement( 58 | Ansi, 59 | null, 60 | "this sentence\rthat\nwill make you pause" 61 | ) 62 | ); 63 | expect(el).not.toBeNull(); 64 | expect(el.text()).toBe("that sentence\nwill make you pause"); 65 | }); 66 | 67 | test("can handle backspace symbol", () => { 68 | const el = shallow( 69 | React.createElement( 70 | Ansi, 71 | null, 72 | "01hello\b goodbye" 73 | ) 74 | ); 75 | expect(el).not.toBeNull(); 76 | expect(el.text()).toBe("01hell goodbye"); 77 | }); 78 | 79 | // see https://stackoverflow.com/questions/55440152/multiple-b-doesnt-work-as-expected-in-jupyter# 80 | test("handles backspace symbol in same funny way as Jupyter Classic -- 1/2", () => { 81 | const el = shallow( 82 | React.createElement( 83 | Ansi, 84 | null, 85 | "02hello\b\b goodbye" 86 | ) 87 | ); 88 | expect(el).not.toBeNull(); 89 | expect(el.text()).toBe("02hel goodbye"); 90 | }); 91 | 92 | test("handles backspace symbol in same funny way as Jupyter Classic -- 2/2", () => { 93 | const el = shallow( 94 | React.createElement( 95 | Ansi, 96 | null, 97 | "03hello\b\b\b goodbye" 98 | ) 99 | ); 100 | expect(el).not.toBeNull(); 101 | expect(el.text()).toBe("03hell goodbye"); 102 | }); 103 | 104 | test("can linkify", () => { 105 | const el = shallow( 106 | React.createElement( 107 | Ansi, 108 | { linkify: true }, 109 | "this is a link: https://nteract.io/" 110 | ) 111 | ); 112 | expect(el).not.toBeNull(); 113 | expect(el.text()).toBe("this is a link: https://nteract.io/"); 114 | expect(el.html()).toBe( 115 | 'this is a link: https://nteract.io/' 116 | ); 117 | }); 118 | 119 | test("can linkify links starting with www.", () => { 120 | const el = shallow( 121 | React.createElement( 122 | Ansi, 123 | { linkify: true }, 124 | "this is a link: www.google.com" 125 | ) 126 | ); 127 | expect(el).not.toBeNull(); 128 | expect(el.text()).toBe("this is a link: www.google.com"); 129 | expect(el.html()).toBe( 130 | 'this is a link: www.google.com' 131 | ); 132 | }); 133 | 134 | test("doesn't linkify partial matches", () => { 135 | const el = shallow( 136 | React.createElement( 137 | Ansi, 138 | { linkify: true }, 139 | "cant click this link: 'http://www.google.com'" 140 | ) 141 | ); 142 | expect(el).not.toBeNull(); 143 | expect(el.text()).toBe("cant click this link: 'http://www.google.com'"); 144 | expect(el.html()).toBe( 145 | "cant click this link: 'http://www.google.com'" 146 | ); 147 | }); 148 | 149 | test("can distinguish URL-ish text", () => { 150 | const el = shallow( 151 | React.createElement( 152 | Ansi, 153 | { linkify: true }, 154 | " { 162 | const el = shallow( 163 | React.createElement( 164 | Ansi, 165 | { linkify: true }, 166 | "" 167 | ) 168 | ); 169 | expect(el).not.toBeNull(); 170 | expect(el.text()).toBe( 171 | "" 172 | ); 173 | }); 174 | 175 | test("can linkify multiple links", () => { 176 | const el = shallow( 177 | React.createElement( 178 | Ansi, 179 | { linkify: true }, 180 | "this is a link: www.google.com and this is a second link: www.microsoft.com" 181 | ) 182 | ); 183 | expect(el).not.toBeNull(); 184 | expect(el.text()).toBe("this is a link: www.google.com and this is a second link: www.microsoft.com"); 185 | expect(el.html()).toBe( 186 | 'this is a link: www.google.com and this is a second link: www.microsoft.com' 187 | ); 188 | }); 189 | 190 | test("creates a minimal number of nodes when using linkify", () => { 191 | const el = shallow( 192 | React.createElement( 193 | Ansi, 194 | { linkify: true }, 195 | "this is a link: www.google.com and this is text after" 196 | ) 197 | ); 198 | expect(el).not.toBeNull(); 199 | expect(el.text()).toBe("this is a link: www.google.com and this is text after"); 200 | expect(el.childAt(0).children()).toHaveLength(3); 201 | }); 202 | 203 | test("can linkify multiple links one after another", () => { 204 | const el = shallow( 205 | React.createElement( 206 | Ansi, 207 | { linkify: true }, 208 | "www.google.com www.google.com www.google.com" 209 | ) 210 | ); 211 | expect(el).not.toBeNull(); 212 | expect(el.text()).toBe("www.google.com www.google.com www.google.com"); 213 | expect(el.html()).toBe( 214 | 'www.google.com www.google.com www.google.com' 215 | ); 216 | }); 217 | 218 | test("can handle URLs inside query parameters", () => { 219 | const el = shallow( 220 | React.createElement( 221 | Ansi, 222 | { linkify: true }, 223 | "www.google.com/?q=https://www.google.com" 224 | ) 225 | ); 226 | expect(el).not.toBeNull(); 227 | expect(el.text()).toBe("www.google.com/?q=https://www.google.com"); 228 | expect(el.html()).toBe( 229 | 'www.google.com/?q=https://www.google.com' 230 | ); 231 | }); 232 | 233 | describe("useClasses options", () => { 234 | test("can add the font color class", () => { 235 | const el = shallow( 236 | React.createElement( 237 | Ansi, 238 | { useClasses: true }, 239 | `hello ${GREEN_FG}world` 240 | ) 241 | ); 242 | expect(el).not.toBeNull(); 243 | expect(el.text()).toBe("hello world"); 244 | expect(el.html()).toBe( 245 | 'hello world' 246 | ); 247 | }); 248 | 249 | test("can add the background color class", () => { 250 | const el = shallow( 251 | React.createElement( 252 | Ansi, 253 | { useClasses: true }, 254 | `hello ${YELLOW_BG}world` 255 | ) 256 | ); 257 | expect(el).not.toBeNull(); 258 | expect(el.text()).toBe("hello world"); 259 | expect(el.html()).toBe( 260 | 'hello world' 261 | ); 262 | }); 263 | 264 | test("can add font and background color classes", () => { 265 | const el = shallow( 266 | React.createElement( 267 | Ansi, 268 | { useClasses: true }, 269 | `hello ${GREEN_FG}${YELLOW_BG}world` 270 | ) 271 | ); 272 | expect(el).not.toBeNull(); 273 | expect(el.text()).toBe("hello world"); 274 | expect(el.html()).toBe( 275 | 'hello world' 276 | ); 277 | }); 278 | 279 | test("can add text decoration classes", () => { 280 | const el = shallow( 281 | React.createElement( 282 | Ansi, 283 | { useClasses: true }, 284 | `hello ${GREEN_FG}${BOLD}world${RESET}!` 285 | ) 286 | ); 287 | expect(el).not.toBeNull(); 288 | expect(el.text()).toBe("hello world!"); 289 | expect(el.html()).toBe( 290 | 'hello world!' 291 | ); 292 | }); 293 | 294 | test("can use useClasses with linkify", () => { 295 | const el = shallow( 296 | React.createElement( 297 | Ansi, 298 | { linkify: true, useClasses: true }, 299 | `${GREEN_FG}this is a link: https://nteract.io/` 300 | ) 301 | ); 302 | expect(el).not.toBeNull(); 303 | expect(el.text()).toBe("this is a link: https://nteract.io/"); 304 | expect(el.html()).toBe( 305 | 'this is a link: https://nteract.io/' 306 | ); 307 | }); 308 | 309 | test("can add text decoration styles", () => { 310 | const el = shallow( 311 | React.createElement( 312 | Ansi, 313 | {}, 314 | `hello ${GREEN_FG}${BOLD}world${RESET}!` 315 | ) 316 | ); 317 | expect(el).not.toBeNull(); 318 | expect(el.text()).toBe("hello world!"); 319 | expect(el.html()).toBe( 320 | 'hello world!' 321 | ); 322 | }); 323 | }); 324 | }); 325 | --------------------------------------------------------------------------------