├── vitest.setup.ts ├── examples ├── nextjs │ ├── .eslintrc.json │ ├── app │ │ ├── favicon.ico │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── globals.css │ ├── next.config.mjs │ ├── postcss.config.js │ ├── components │ │ └── ThemeChanger.tsx │ ├── .gitignore │ ├── public │ │ ├── vercel.svg │ │ └── next.svg │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── package.json │ └── README.md └── vite-react │ ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── App.tsx │ └── index.css │ ├── postcss.config.js │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── .gitignore │ ├── index.html │ ├── tailwind.config.js │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── package.json │ └── README.md ├── postcss.config.js ├── .gitignore ├── index.html ├── tailwind.config.ts ├── .github └── workflows │ ├── publish.yml │ └── ci.yml ├── src ├── ansi-regex.ts ├── __snapshots__ │ └── react.test.tsx.snap ├── plugin.ts ├── react.tsx ├── react.test.tsx ├── main.test.ts ├── parse.test.ts ├── style-attrs.ts ├── colors.ts ├── style-attrs.test.ts ├── parse.ts └── main.ts ├── hack ├── index.tsx ├── index.css └── App.tsx ├── bench ├── index.html ├── strip.ts ├── parse.ts ├── loop.ts └── map.ts ├── tsconfig.json ├── .eslintrc.json ├── vite.config.ts ├── package.json ├── LICENSE └── README.md /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /examples/nextjs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/vite-react/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/nextjs/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kubetail-org/fancy-ansi/HEAD/examples/nextjs/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /examples/nextjs/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /examples/nextjs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /examples/vite-react/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /examples/vite-react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /examples/vite-react/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /examples/vite-react/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import App from './App.tsx'; 5 | 6 | import './index.css'; 7 | 8 | ReactDOM.createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/vite-react/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Fancy-ANSI 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/vite-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/vite-react/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: 'selector', 4 | content: [ 5 | './src/**/*.{ts,tsx}' 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | background: { 11 | DEFAULT: 'var(--background)' 12 | }, 13 | foreground: { 14 | DEFAULT: 'var(--foreground)' 15 | } 16 | } 17 | }, 18 | }, 19 | plugins: [ 20 | require('fancy-ansi/plugin') 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /examples/nextjs/components/ThemeChanger.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export const ThemeChanger = () => { 4 | const handleThemeChange = (ev: React.ChangeEvent) => { 5 | if (ev.target.value === 'dark') document.documentElement.classList.add('dark'); 6 | else document.documentElement.classList.remove('dark'); 7 | }; 8 | 9 | return ( 10 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /examples/vite-react/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import fancyAnsiPlugin from './src/plugin'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | darkMode: 'selector', 6 | content: [ 7 | './hack/**/*.{ts,tsx}' 8 | ], 9 | theme: { 10 | //ansi: 'xxx', 11 | extend: { 12 | colors: { 13 | background: { 14 | DEFAULT: 'var(--background)' 15 | }, 16 | foreground: { 17 | DEFAULT: 'var(--foreground)' 18 | } 19 | } 20 | }, 21 | }, 22 | plugins: [ 23 | fancyAnsiPlugin, 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /examples/nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/nextjs/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /examples/nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import fancyAnsiPlugin from 'fancy-ansi/plugin'; 3 | 4 | const config: Config = { 5 | darkMode: 'selector', 6 | content: [ 7 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 9 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 10 | ], 11 | theme: { 12 | extend: { 13 | colors: { 14 | background: { 15 | DEFAULT: 'var(--background)' 16 | }, 17 | foreground: { 18 | DEFAULT: 'var(--foreground)' 19 | } 20 | } 21 | }, 22 | }, 23 | plugins: [ 24 | fancyAnsiPlugin, 25 | ], 26 | }; 27 | 28 | export default config; 29 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '**' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | packages: write 13 | contents: read 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | registry-url: https://registry.npmjs.org 20 | - uses: pnpm/action-setup@v3 21 | with: 22 | version: 8 23 | - run: pnpm install 24 | - run: pnpm build 25 | - run: pnpm publish --no-git-checks --access public 26 | env: 27 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 28 | -------------------------------------------------------------------------------- /examples/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "fancy-ansi": "^0.1.2", 13 | "react": "^18", 14 | "react-dom": "^18", 15 | "next": "14.1.4" 16 | }, 17 | "devDependencies": { 18 | "typescript": "^5", 19 | "@types/node": "^20", 20 | "@types/react": "^18", 21 | "@types/react-dom": "^18", 22 | "autoprefixer": "^10.0.1", 23 | "postcss": "^8", 24 | "tailwindcss": "^3.3.0", 25 | "eslint": "^8", 26 | "eslint-config-next": "14.1.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ansi-regex.ts: -------------------------------------------------------------------------------- 1 | // Embedding this here because the ansi-regex import is 2 | // triggering an jspm error (https://github.com/kubetail-org/fancy-ansi/issues/16) 3 | // https://github.com/chalk/ansi-regex/blob/main/index.js 4 | export default function ansiRegex({ onlyFirst = false } = {}) { 5 | // Valid string terminator sequences are BEL, ESC\, and 0x9c 6 | const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)'; 7 | const pattern = [ 8 | `[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`, 9 | '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', 10 | ].join('|'); 11 | 12 | return new RegExp(pattern, onlyFirst ? undefined : 'g'); 13 | } 14 | -------------------------------------------------------------------------------- /examples/vite-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /examples/nextjs/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { AnsiHtml } from 'fancy-ansi/react'; 2 | 3 | import { ThemeChanger } from '@/components/ThemeChanger'; 4 | 5 | export default function Home() { 6 | const ansiStr1 = '\x1b[1mThis is in bold\x1b[0m'; 7 | const ansiStr2 = '\x1b[3mThis is in italics\x1b[0m'; 8 | const ansiStr3 = '\x1b[31mThis is in red\x1b[0m'; 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 |
16 |
17 |
    18 |
  • 19 |
  • 20 |
  • 21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /hack/index.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import React from 'react'; 16 | import ReactDOM from 'react-dom/client'; 17 | 18 | import App from './App'; 19 | import "./index.css"; 20 | 21 | ReactDOM.createRoot(document.getElementById('root')!).render( 22 | 23 | 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /src/__snapshots__/react.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`AnsiHtml > passes through className 1`] = ` 4 | 5 | 8 | 11 | bold 12 | 13 | 14 | 15 | `; 16 | 17 | exports[`AnsiHtml > passes through style 1`] = ` 18 | 19 | 22 | 25 | bold 26 | 27 | 28 | 29 | `; 30 | 31 | exports[`AnsiHtml > renders properly 1`] = ` 32 | 33 | 34 | 37 | bold 38 | 39 | 40 | 41 | `; 42 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import plugin from 'tailwindcss/plugin'; 16 | 17 | import * as colors from './colors'; 18 | 19 | /** 20 | * Plugin 21 | */ 22 | export default plugin( 23 | () => {}, // do nothing 24 | { 25 | theme: { 26 | ansi: { 27 | colors: { 28 | ...colors, 29 | }, 30 | }, 31 | }, 32 | }, 33 | ); 34 | -------------------------------------------------------------------------------- /examples/vite-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "fancy-ansi": "^0.1.2", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.73", 19 | "@types/react-dom": "^18.2.23", 20 | "@typescript-eslint/eslint-plugin": "^7.4.0", 21 | "@typescript-eslint/parser": "^7.4.0", 22 | "@vitejs/plugin-react": "^4.2.1", 23 | "autoprefixer": "^10.4.19", 24 | "eslint": "^8.57.0", 25 | "eslint-plugin-react-hooks": "^4.6.0", 26 | "eslint-plugin-react-refresh": "^0.4.6", 27 | "postcss": "^8.4.38", 28 | "tailwindcss": "^3.4.3", 29 | "typescript": "^5.4.3", 30 | "vite": "^5.2.6" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '**' 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - uses: pnpm/action-setup@v3 17 | with: 18 | version: 8 19 | - run: pnpm install 20 | - run: pnpm lint 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | - uses: pnpm/action-setup@v3 30 | with: 31 | version: 8 32 | - run: pnpm install 33 | - run: pnpm test run 34 | 35 | build: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-node@v4 40 | with: 41 | node-version: 20 42 | - uses: pnpm/action-setup@v3 43 | with: 44 | version: 8 45 | - run: pnpm install 46 | - run: pnpm build 47 | -------------------------------------------------------------------------------- /examples/vite-react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { AnsiHtml } from 'fancy-ansi/react'; 2 | 3 | function App() { 4 | const handleThemeChange = (ev: React.ChangeEvent) => { 5 | if (ev.target.value === 'dark') document.documentElement.classList.add('dark'); 6 | else document.documentElement.classList.remove('dark'); 7 | }; 8 | 9 | const ansiStr1 = '\x1b[1mThis is in bold\x1b[0m'; 10 | const ansiStr2 = '\x1b[3mThis is in italics\x1b[0m'; 11 | const ansiStr3 = '\x1b[31mThis is in red\x1b[0m'; 12 | 13 | return ( 14 |
15 |
16 | 17 | 21 |
22 |
23 |
    24 |
  • 25 |
  • 26 |
  • 27 |
28 |
29 |
30 | ); 31 | } 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /src/react.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import type { CSSProperties } from 'react'; 16 | 17 | import { FancyAnsi } from '@/main'; 18 | 19 | const fancyAnsi = new FancyAnsi(); 20 | 21 | export type AnsiHtmlProps = { 22 | className?: string; 23 | text?: string; 24 | style?: CSSProperties; 25 | }; 26 | 27 | export const AnsiHtml = ({ className, style, text = '' }: AnsiHtmlProps) => ( 28 | 33 | ); 34 | -------------------------------------------------------------------------------- /bench/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Fancy-ANSI Benchmarks 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ES2020", 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ESNext" 9 | ], 10 | "declaration": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | 14 | /* bundler mode */ 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | //"isolatedModules": true, 18 | "preserveConstEnums": false, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | 22 | /* linting */ 23 | "strict": true, 24 | "forceConsistentCasingInFileNames": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "noImplicitAny": true, 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": true, 29 | 30 | /* import paths */ 31 | "baseUrl": ".", 32 | "paths": { 33 | "@/*": [ 34 | "./src/*" 35 | ] 36 | }, 37 | 38 | /* misc */ 39 | "types": [ 40 | "vitest/globals" 41 | ] 42 | }, 43 | "include": [ 44 | "src/**/*.js", 45 | "src/**/*.ts", 46 | "src/**/*.tsx", 47 | "hack/**/*.ts", 48 | "hack/**/*.tsx" 49 | ], 50 | "exclude": [ 51 | "node_modules", 52 | "dist", 53 | "src/**/*.test.ts", 54 | "src/**/*.test.tsx" 55 | ] 56 | } -------------------------------------------------------------------------------- /examples/nextjs/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/vite-react/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /src/react.test.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { render } from '@testing-library/react'; 16 | import React from 'react'; 17 | 18 | import { AnsiHtml } from './react'; 19 | 20 | describe('AnsiHtml', () => { 21 | it('renders properly', () => { 22 | const x = '\x1b[1mbold\x1b[0m'; 23 | const { asFragment } = render(); 24 | expect(asFragment()).toMatchSnapshot(); 25 | }); 26 | 27 | it('passes through className', () => { 28 | const x = '\x1b[1mbold\x1b[0m'; 29 | const { asFragment } = render(); 30 | expect(asFragment()).toMatchSnapshot(); 31 | }); 32 | 33 | it('passes through style', () => { 34 | const x = '\x1b[1mbold\x1b[0m'; 35 | const { asFragment } = render(); 36 | expect(asFragment()).toMatchSnapshot(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /bench/strip.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { Suite } from 'benchmark'; 16 | 17 | import { stripAnsi } from '../src/main'; 18 | 19 | const suite = new Suite('Strip function comparisons'); 20 | 21 | //const x = '\x1b[1mbold\x1b[0m'; 22 | const x = '[17:19:30 INF] 26 https  GET /api/example responded 200 in 0.1650 ms'; 23 | 24 | suite 25 | .add('strip1', () => { 26 | stripAnsi(x); 27 | }) 28 | .on('cycle', function (event) { 29 | console.log(event.target); 30 | }) 31 | .on('complete', function () { 32 | console.log('Fastest is ' + this.filter('fastest').map('name')); 33 | }) 34 | .run({ 'async': true }); 35 | -------------------------------------------------------------------------------- /bench/parse.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { Suite } from 'benchmark'; 16 | 17 | import { Parser } from '../src/parse'; 18 | 19 | const suite = new Suite('Parse function comparisons'); 20 | 21 | //const x = '\x1b[1mbold\x1b[0m'; 22 | const x = '[17:19:30 INF] 26 https  GET /api/example responded 200 in 0.1650 ms'; 23 | 24 | 25 | suite 26 | .add('parse', () => { 27 | Array.from(new Parser(x)); 28 | }) 29 | .on('cycle', function (event) { 30 | console.log(event.target); 31 | }) 32 | .on('complete', function () { 33 | console.log('Fastest is ' + this.filter('fastest').map('name')); 34 | }) 35 | .run({ 'async': true }); 36 | -------------------------------------------------------------------------------- /bench/loop.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { Suite } from 'benchmark'; 16 | 17 | const testObj = { a: '1', b: '2', c: '3', d: '4' }; 18 | 19 | (new Suite('object inner loops')) 20 | .add('obj-for', () => { 21 | const x = { ...testObj }; 22 | let y: any; 23 | for (const key in x) { 24 | if (x.hasOwnProperty(key)) { 25 | y = x[key]; 26 | }; 27 | } 28 | }) 29 | .add('obj-entries-foreach', () => { 30 | const x = { ...testObj }; 31 | let y: any; 32 | Object.entries(x).forEach(([, value]) => { 33 | y = value; 34 | }); 35 | }) 36 | .add('obj-entries-for', () => { 37 | const x = { ...testObj }; 38 | let y: any; 39 | for (const [, value] of Object.entries(x)) { 40 | y = value; 41 | }; 42 | }) 43 | .on('cycle', function (event) { 44 | console.log(event.target); 45 | }) 46 | .on('complete', function () { 47 | console.log('Fastest is ' + this.filter('fastest').map('name')); 48 | }) 49 | .run({ 'async': true }); 50 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "plugins": [ 4 | "@typescript-eslint" 5 | ], 6 | "extends": [ 7 | "airbnb", 8 | "airbnb-typescript", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:storybook/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "project": "./tsconfig.json" 15 | }, 16 | "ignorePatterns": [ 17 | "*.test.ts", 18 | "*.test.tsx", 19 | "*.stories.tsx" 20 | ], 21 | "rules": { 22 | "class-methods-use-this": "off", // TODO: remove this when not necessary 23 | "indent": "off", 24 | "linebreak-style": ["error", "unix"], 25 | "no-bitwise": "off", 26 | "no-console": "off", 27 | "no-control-regex": "off", 28 | "no-prototype-builtins": "off", // in order to support .hasOwnProperty() loop optimization 29 | "no-restricted-syntax": "off", // in order to support `for (const key in )` loop optimization 30 | "no-underscore-dangle": "off", 31 | "import/extensions": "off", 32 | "import/no-extraneous-dependencies": "off", 33 | "import/prefer-default-export": "off", 34 | "jsx-a11y/anchor-is-valid": "off", 35 | "jsx-a11y/click-events-have-key-events": "off", 36 | "jsx-a11y/label-has-associated-control": "off", 37 | "jsx-a11y/no-static-element-interactions": "off", 38 | "prefer-destructuring": "off", 39 | "react/prop-types": "off", 40 | "react/jsx-props-no-spreading": "off", 41 | "react/function-component-definition": "off", 42 | "react/jsx-one-expression-per-line": "off", 43 | "react/no-danger": "off", 44 | "react/react-in-jsx-scope": "off", 45 | "react/require-default-props": "off", 46 | "yoda": "off" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | /// 16 | import typescript from '@rollup/plugin-typescript'; 17 | import react from '@vitejs/plugin-react'; 18 | import { resolve } from 'path'; 19 | import autoExternal from 'rollup-plugin-auto-external'; 20 | import { defineConfig } from 'vite'; 21 | 22 | export default defineConfig({ 23 | plugins: [ 24 | react(), 25 | ], 26 | resolve: { 27 | alias: { 28 | '@': resolve(__dirname, './src'), 29 | }, 30 | }, 31 | build: { 32 | lib: { 33 | entry: [ 34 | resolve(__dirname, 'src/main.ts'), 35 | resolve(__dirname, 'src/colors.ts'), 36 | resolve(__dirname, 'src/plugin.ts'), 37 | resolve(__dirname, 'src/react.tsx'), 38 | ], 39 | name: 'fancyAnsi', 40 | formats: ["es", "cjs"], 41 | }, 42 | rollupOptions: { 43 | plugins: [ 44 | typescript({ 45 | sourceMap: false, 46 | declaration: true, 47 | outDir: "dist", 48 | include: ['src/**/*'] 49 | }), 50 | autoExternal(), 51 | ], 52 | external: ['react', 'react-dom', 'react/jsx-runtime'], 53 | }, 54 | }, 55 | test: { 56 | environment: 'jsdom', 57 | globals: true, 58 | setupFiles: ['vitest.setup.ts'] 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /hack/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: theme(colors.white); 8 | --foreground: theme(colors.gray.900); 9 | 10 | --ansi-black: theme(ansi.colors.vscode.black); 11 | --ansi-red: theme(ansi.colors.vscode.red); 12 | --ansi-green: theme(ansi.colors.vscode.green); 13 | --ansi-yellow: theme(ansi.colors.vscode.yellow); 14 | --ansi-blue: theme(ansi.colors.vscode.blue); 15 | --ansi-magenta: theme(ansi.colors.vscode.magenta); 16 | --ansi-cyan: theme(ansi.colors.vscode.cyan); 17 | --ansi-white: theme(ansi.colors.vscode.white); 18 | --ansi-bright-black: theme(ansi.colors.vscode.bright-black); 19 | --ansi-bright-red: theme(ansi.colors.vscode.bright-red); 20 | --ansi-bright-green: theme(ansi.colors.vscode.bright-green); 21 | --ansi-bright-yellow: theme(ansi.colors.vscode.bright-yellow); 22 | --ansi-bright-blue: theme(ansi.colors.vscode.bright-blue); 23 | --ansi-bright-magenta: theme(ansi.colors.vscode.magenta); 24 | --ansi-bright-cyan: theme(ansi.colors.vscode.cyan); 25 | --ansi-bright-white: theme(ansi.colors.vscode.white); 26 | } 27 | 28 | .dark { 29 | --background: theme(colors.black); 30 | --foreground: theme(colors.gray.50); 31 | 32 | --ansi-black: theme(ansi.colors.xtermjs.black); 33 | --ansi-red: theme(ansi.colors.xtermjs.red); 34 | --ansi-green: theme(ansi.colors.xtermjs.green); 35 | --ansi-yellow: theme(ansi.colors.xtermjs.yellow); 36 | --ansi-blue: theme(ansi.colors.xtermjs.blue); 37 | --ansi-magenta: theme(ansi.colors.xtermjs.magenta); 38 | --ansi-cyan: theme(ansi.colors.xtermjs.cyan); 39 | --ansi-white: theme(ansi.colors.xtermjs.white); 40 | --ansi-bright-black: theme(ansi.colors.xtermjs.bright-black); 41 | --ansi-bright-red: theme(ansi.colors.xtermjs.bright-red); 42 | --ansi-bright-green: theme(ansi.colors.xtermjs.bright-green); 43 | --ansi-bright-yellow: theme(ansi.colors.xtermjs.bright-yellow); 44 | --ansi-bright-blue: theme(ansi.colors.xtermjs.bright-blue); 45 | --ansi-bright-magenta: theme(ansi.colors.xtermjs.magenta); 46 | --ansi-bright-cyan: theme(ansi.colors.xtermjs.cyan); 47 | --ansi-bright-white: theme(ansi.colors.xtermjs.white); 48 | } 49 | 50 | body { 51 | @apply bg-background text-foreground; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/nextjs/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: theme('colors.white'); 8 | --foreground: theme('colors.gray.900'); 9 | 10 | --ansi-black: theme(ansi.colors.vscode.black); 11 | --ansi-red: theme(ansi.colors.vscode.red); 12 | --ansi-green: theme(ansi.colors.vscode.green); 13 | --ansi-yellow: theme(ansi.colors.vscode.yellow); 14 | --ansi-blue: theme(ansi.colors.vscode.blue); 15 | --ansi-magenta: theme(ansi.colors.vscode.magenta); 16 | --ansi-cyan: theme(ansi.colors.vscode.cyan); 17 | --ansi-white: theme(ansi.colors.vscode.white); 18 | --ansi-bright-black: theme(ansi.colors.vscode.bright-black); 19 | --ansi-bright-red: theme(ansi.colors.vscode.bright-red); 20 | --ansi-bright-green: theme(ansi.colors.vscode.bright-green); 21 | --ansi-bright-yellow: theme(ansi.colors.vscode.bright-yellow); 22 | --ansi-bright-blue: theme(ansi.colors.vscode.bright-blue); 23 | --ansi-bright-magenta: theme(ansi.colors.vscode.magenta); 24 | --ansi-bright-cyan: theme(ansi.colors.vscode.cyan); 25 | --ansi-bright-white: theme(ansi.colors.vscode.white); 26 | } 27 | 28 | .dark { 29 | --background: theme('colors.black'); 30 | --foreground: theme('colors.gray.50'); 31 | 32 | --ansi-black: theme(ansi.colors.xtermjs.black); 33 | --ansi-red: theme(ansi.colors.xtermjs.red); 34 | --ansi-green: theme(ansi.colors.xtermjs.green); 35 | --ansi-yellow: theme(ansi.colors.xtermjs.yellow); 36 | --ansi-blue: theme(ansi.colors.xtermjs.blue); 37 | --ansi-magenta: theme(ansi.colors.xtermjs.magenta); 38 | --ansi-cyan: theme(ansi.colors.xtermjs.cyan); 39 | --ansi-white: theme(ansi.colors.xtermjs.white); 40 | --ansi-bright-black: theme(ansi.colors.xtermjs.bright-black); 41 | --ansi-bright-red: theme(ansi.colors.xtermjs.bright-red); 42 | --ansi-bright-green: theme(ansi.colors.xtermjs.bright-green); 43 | --ansi-bright-yellow: theme(ansi.colors.xtermjs.bright-yellow); 44 | --ansi-bright-blue: theme(ansi.colors.xtermjs.bright-blue); 45 | --ansi-bright-magenta: theme(ansi.colors.xtermjs.magenta); 46 | --ansi-bright-cyan: theme(ansi.colors.xtermjs.cyan); 47 | --ansi-bright-white: theme(ansi.colors.xtermjs.white); 48 | } 49 | 50 | body { 51 | @apply bg-background text-foreground; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/vite-react/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: theme('colors.white'); 8 | --foreground: theme('colors.gray.900'); 9 | 10 | --ansi-black: theme(ansi.colors.vscode.black); 11 | --ansi-red: theme(ansi.colors.vscode.red); 12 | --ansi-green: theme(ansi.colors.vscode.green); 13 | --ansi-yellow: theme(ansi.colors.vscode.yellow); 14 | --ansi-blue: theme(ansi.colors.vscode.blue); 15 | --ansi-magenta: theme(ansi.colors.vscode.magenta); 16 | --ansi-cyan: theme(ansi.colors.vscode.cyan); 17 | --ansi-white: theme(ansi.colors.vscode.white); 18 | --ansi-bright-black: theme(ansi.colors.vscode.bright-black); 19 | --ansi-bright-red: theme(ansi.colors.vscode.bright-red); 20 | --ansi-bright-green: theme(ansi.colors.vscode.bright-green); 21 | --ansi-bright-yellow: theme(ansi.colors.vscode.bright-yellow); 22 | --ansi-bright-blue: theme(ansi.colors.vscode.bright-blue); 23 | --ansi-bright-magenta: theme(ansi.colors.vscode.magenta); 24 | --ansi-bright-cyan: theme(ansi.colors.vscode.cyan); 25 | --ansi-bright-white: theme(ansi.colors.vscode.white); 26 | } 27 | 28 | .dark { 29 | --background: theme('colors.black'); 30 | --foreground: theme('colors.gray.50'); 31 | 32 | --ansi-black: theme(ansi.colors.xtermjs.black); 33 | --ansi-red: theme(ansi.colors.xtermjs.red); 34 | --ansi-green: theme(ansi.colors.xtermjs.green); 35 | --ansi-yellow: theme(ansi.colors.xtermjs.yellow); 36 | --ansi-blue: theme(ansi.colors.xtermjs.blue); 37 | --ansi-magenta: theme(ansi.colors.xtermjs.magenta); 38 | --ansi-cyan: theme(ansi.colors.xtermjs.cyan); 39 | --ansi-white: theme(ansi.colors.xtermjs.white); 40 | --ansi-bright-black: theme(ansi.colors.xtermjs.bright-black); 41 | --ansi-bright-red: theme(ansi.colors.xtermjs.bright-red); 42 | --ansi-bright-green: theme(ansi.colors.xtermjs.bright-green); 43 | --ansi-bright-yellow: theme(ansi.colors.xtermjs.bright-yellow); 44 | --ansi-bright-blue: theme(ansi.colors.xtermjs.bright-blue); 45 | --ansi-bright-magenta: theme(ansi.colors.xtermjs.magenta); 46 | --ansi-bright-cyan: theme(ansi.colors.xtermjs.cyan); 47 | --ansi-bright-white: theme(ansi.colors.xtermjs.white); 48 | } 49 | 50 | body { 51 | @apply bg-background text-foreground; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fancy-ansi", 3 | "version": "0.1.3", 4 | "description": "Small JavaScript library for converting ANSI to HTML", 5 | "license": "Apache-2.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/kubetail-org/fancy-ansi.git" 9 | }, 10 | "type": "module", 11 | "sideEffects": false, 12 | "main": "./dist/main.cjs", 13 | "module": "./dist/main.js", 14 | "exports": { 15 | ".": { 16 | "import": "./dist/main.js", 17 | "require": "./dist/main.cjs" 18 | }, 19 | "./colors": { 20 | "import": "./dist/colors.js", 21 | "require": "./dist/colors.cjs" 22 | }, 23 | "./plugin": { 24 | "import": "./dist/plugin.js", 25 | "require": "./dist/plugin.cjs" 26 | }, 27 | "./react": { 28 | "import": "./dist/react.js", 29 | "require": "./dist/react.cjs" 30 | } 31 | }, 32 | "files": [ 33 | "/dist" 34 | ], 35 | "scripts": { 36 | "build": "tsc && vite build", 37 | "build-bench": "vite build --config vite-bench.config.ts", 38 | "dev": "vite", 39 | "lint": "eslint \"./src/**/*.ts{,x}\"", 40 | "test": "vitest" 41 | }, 42 | "devDependencies": { 43 | "@rollup/plugin-typescript": "^11.1.6", 44 | "@testing-library/jest-dom": "^6.4.2", 45 | "@testing-library/react": "^14.2.2", 46 | "@types/benchmark": "^2.1.5", 47 | "@types/escape-html": "^1.0.4", 48 | "@types/jest": "^29.5.12", 49 | "@types/node": "^20.12.5", 50 | "@types/react": "^18.2.74", 51 | "@types/react-dom": "^18.2.24", 52 | "@typescript-eslint/eslint-plugin": "^7.5.0", 53 | "@typescript-eslint/parser": "^7.5.0", 54 | "@vitejs/plugin-react": "^4.2.1", 55 | "ansi_up": "^6.0.2", 56 | "autoprefixer": "^10.4.19", 57 | "beautify-benchmark": "^0.2.4", 58 | "benchmark": "^2.1.4", 59 | "eslint": "^8.57.0", 60 | "eslint-config-airbnb": "^19.0.4", 61 | "eslint-config-airbnb-typescript": "^18.0.0", 62 | "eslint-plugin-jsx-a11y": "^6.8.0", 63 | "eslint-plugin-react": "^7.34.1", 64 | "eslint-plugin-react-hooks": "^4.6.0", 65 | "eslint-plugin-storybook": "^0.8.0", 66 | "jsdom": "^24.0.0", 67 | "microtime": "^3.1.1", 68 | "postcss": "^8.4.38", 69 | "react": "^18.2.0", 70 | "react-dom": "^18.2.0", 71 | "rollup-plugin-auto-external": "^2.0.0", 72 | "tailwindcss": "^3.4.3", 73 | "typescript": "^5.4.4", 74 | "vite": "^5.2.8", 75 | "vitest": "^1.4.0" 76 | }, 77 | "dependencies": { 78 | "escape-html": "^1.0.3" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { FancyAnsi, hasAnsi, stripAnsi } from './main'; 16 | 17 | describe('FancyAnsi', () => { 18 | it('escapes html in input strings', () => { 19 | const fancyAnsi = new FancyAnsi(); 20 | const output = fancyAnsi.toHtml(''); 21 | expect(output).toEqual('<script>alert("hi");</script>'); 22 | }); 23 | 24 | it('ignores dangling resets', () => { 25 | const fancyAnsi = new FancyAnsi(); 26 | const output = fancyAnsi.toHtml('\x1b[0m'); 27 | expect(output).toEqual(''); 28 | }); 29 | 30 | it('adds closing tag even if reset isnt present', () => { 31 | const fancyAnsi = new FancyAnsi(); 32 | const output = fancyAnsi.toHtml('\x1b[1mbold'); 33 | expect(output).toEqual('bold'); 34 | }); 35 | 36 | it('ignores extra dangling resets', () => { 37 | const fancyAnsi = new FancyAnsi(); 38 | const output = fancyAnsi.toHtml('\x1b[1mbold\x1b[0m\x1b[0m'); 39 | expect(output).toEqual('bold'); 40 | }); 41 | 42 | it('treats missing code as reset', () => { 43 | const fancyAnsi = new FancyAnsi(); 44 | const output = fancyAnsi.toHtml('\x1b[1mon\x1b[moff'); 45 | expect(output).toEqual('onoff'); 46 | }); 47 | 48 | it('treats missing codes as reset', () => { 49 | const fancyAnsi = new FancyAnsi(); 50 | const output = fancyAnsi.toHtml('\x1b[1mon\x1b[;moff'); 51 | expect(output).toEqual('onoff'); 52 | }); 53 | 54 | it('ignores extra reset at start', () => { 55 | const fancyAnsi = new FancyAnsi(); 56 | const output = fancyAnsi.toHtml('\x1b[0m\x1b[1mbold\x1b[0m'); 57 | expect(output).toEqual('bold'); 58 | }); 59 | }); 60 | 61 | describe('hasAnsi', () => { 62 | it('returns false when there is no ansi markup', () => { 63 | const result = hasAnsi('xxx'); 64 | expect(result).toEqual(false); 65 | }); 66 | 67 | it('returns true when there is an SGR code', () => { 68 | const result = hasAnsi('\x1b[0m'); 69 | expect(result).toEqual(true); 70 | }); 71 | 72 | it('returns true when there are other escape codes', () => { 73 | const result = hasAnsi('\x1b[10;5H'); 74 | expect(result).toEqual(true); 75 | }); 76 | }); 77 | 78 | describe('stripAnsi', () => { 79 | it('returns same string when there is no markup', () => { 80 | const result = stripAnsi('xxx'); 81 | expect(result).toEqual('xxx'); 82 | }); 83 | 84 | it('returns empty string when there is only one escape code', () => { 85 | const result = stripAnsi('\x1b[0m'); 86 | expect(result).toEqual(''); 87 | }); 88 | 89 | it('returns empty string when there are only escape codes', () => { 90 | const result = stripAnsi('\x1b[1m\x1b[0m'); 91 | expect(result).toEqual(''); 92 | }); 93 | 94 | it('returns string without escape codes', () => { 95 | const result = stripAnsi('one\x1b[1mtwo\x1b[0mthree'); 96 | expect(result).toEqual('onetwothree'); 97 | }); 98 | 99 | it('handles spaces properly', () => { 100 | const result = stripAnsi('one \x1b[1m two \x1b[0m three'); 101 | expect(result).toEqual('one two three'); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/parse.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { Parser, PacketKind } from './parse'; 16 | 17 | // helper method for tests 18 | const parse = (input: string) => { 19 | return Array.from(new Parser(input)); 20 | } 21 | 22 | describe('parse', () => { 23 | it('returns empty list if input string is empty', () => { 24 | const packets = parse(''); 25 | expect(packets.length).toEqual(0); 26 | }); 27 | 28 | it('returns one packet of kind `Text` for a string without escape codes', () => { 29 | const packets = parse('xxx'); 30 | expect(packets.length).toEqual(1); 31 | expect(packets[0].kind).toEqual(PacketKind.Text); 32 | expect(packets[0].text).toEqual('xxx'); 33 | }); 34 | 35 | it('returns one packet of kind `SGR` for a string with one escape code', () => { 36 | const packets = parse('\x1b[0m'); 37 | expect(packets.length).toEqual(1); 38 | expect(packets[0].kind).toEqual(PacketKind.SGR); 39 | expect(packets[0].text).toEqual('0'); 40 | }); 41 | 42 | it('returns one packet of kind `SGR` for a string with no escape codes', () => { 43 | const packets = parse('\x1b[m'); 44 | expect(packets.length).toEqual(1); 45 | expect(packets[0].kind).toEqual(PacketKind.SGR); 46 | expect(packets[0].text).toEqual(''); 47 | }); 48 | 49 | it('returns one packet of kind `SGR` for a string with semicolon but no codes', () => { 50 | const packets = parse('\x1b[;m'); 51 | expect(packets.length).toEqual(1); 52 | expect(packets[0].kind).toEqual(PacketKind.SGR); 53 | expect(packets[0].text).toEqual(';'); 54 | }); 55 | 56 | it('returns packets of kind `OSCURL` for strings with non-SGR, non-OSCURL escape codes', () => { 57 | const packets = parse('\x1b]8;;http://example.com\x07click me\x1b]8;;\x07'); 58 | expect(packets.length).toEqual(1); 59 | expect(packets[0].kind).toEqual(PacketKind.OSCURL); 60 | expect(packets[0].text).toEqual('click me'); 61 | expect(packets[0].url).toEqual('http://example.com'); 62 | }); 63 | 64 | it('returns packets of kind `Unknown` for strings with non-SGR, non-OSCURL escape codes', () => { 65 | const packets = parse('\x1b[10;5H'); 66 | expect(packets.length).toEqual(1); 67 | expect(packets[0].kind).toEqual(PacketKind.Unknown); 68 | }); 69 | 70 | it('returns three packets for escape code betwen text', () => { 71 | const packets = parse('before\x1b[0mafter'); 72 | expect(packets.length).toEqual(3); 73 | 74 | expect(packets[0].kind).toEqual(PacketKind.Text); 75 | expect(packets[0].text).toEqual('before'); 76 | 77 | expect(packets[1].kind).toEqual(PacketKind.SGR); 78 | expect(packets[1].text).toEqual('0'); 79 | 80 | expect(packets[2].kind).toEqual(PacketKind.Text); 81 | expect(packets[2].text).toEqual('after'); 82 | }); 83 | 84 | it('returns three packets for text between escape codes', () => { 85 | const packets = parse('\x1b[1mbetween\x1b[0m'); 86 | expect(packets.length).toEqual(3); 87 | 88 | expect(packets[0].kind).toEqual(PacketKind.SGR); 89 | expect(packets[0].text).toEqual('1'); 90 | 91 | expect(packets[1].kind).toEqual(PacketKind.Text); 92 | expect(packets[1].text).toEqual('between'); 93 | 94 | expect(packets[2].kind).toEqual(PacketKind.SGR); 95 | expect(packets[2].text).toEqual('0'); 96 | }); 97 | 98 | it('includes multiple codes if present', () => { 99 | const packets = parse('\x1b[1;3;4m'); 100 | expect(packets.length).toEqual(1); 101 | expect(packets[0].kind).toEqual(PacketKind.SGR); 102 | expect(packets[0].text).toEqual('1;3;4'); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /bench/map.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { Suite } from 'benchmark'; 16 | 17 | const testObj = { a: '1', b: '2', c: '3', d: '4' }; 18 | const testMap = new Map([['a', 1], ['b', 2], ['c', 3], ['d', 4]]); 19 | 20 | // object benchmarks 21 | (new Suite('object loops')) 22 | .add('obj-for', () => { 23 | const x = {}; 24 | const y = { ...testObj }; 25 | for (const key in y) { 26 | if (y.hasOwnProperty(key)) x[key] = y[key]; 27 | } 28 | }) 29 | .add('obj-spread', () => { 30 | const x = {}; 31 | const y = { ...testObj }; 32 | const _ = { ...x, ...y }; 33 | }) 34 | .add('obj-assign', () => { 35 | const x = {}; 36 | const y = { ...testObj }; 37 | Object.assign(x, y); 38 | }) 39 | .add('obj-entries-foreach', () => { 40 | const x = {}; 41 | const y = { ...testObj }; 42 | Object.entries(y).forEach(([key, value]) => { 43 | x[key] = value; 44 | }) 45 | }) 46 | .add('obj-entries-for', () => { 47 | const x = {}; 48 | const y = { ...testObj }; 49 | for (const [key, value] of Object.entries(y)) { 50 | x[key] = value; 51 | } 52 | }) 53 | .on('cycle', function (event) { 54 | console.log(event.target); 55 | }) 56 | .on('complete', function () { 57 | console.log('Fastest is ' + this.filter('fastest').map('name')); 58 | }) 59 | .run({ 'async': true }); 60 | 61 | // map benchmarks 62 | (new Suite('map loops')) 63 | .add('map-for-loop', () => { 64 | const x = new Map(); 65 | const y = new Map(testMap); 66 | for (const [key, val] of y.entries()) { 67 | x.set(key, val); 68 | } 69 | }) 70 | .add('map-foreach-loop', () => { 71 | const x = new Map(); 72 | const y = new Map(testMap); 73 | y.forEach((val, key) => { 74 | x.set(val, key); 75 | }); 76 | }) 77 | .add('map-spread', () => { 78 | const x = new Map(); 79 | const y = new Map(testMap); 80 | const _ = new Map([...x, ...y]); 81 | }) 82 | .on('cycle', function (event) { 83 | console.log(event.target); 84 | }) 85 | .on('complete', function () { 86 | console.log('Fastest is ' + this.filter('fastest').map('name')); 87 | }) 88 | .run({ 'async': true }); 89 | 90 | // comparison 91 | (new Suite('map vs object - merge')) 92 | .add('merge: map', () => { 93 | const _1 = {}; 94 | const _2 = { ...testObj }; 95 | 96 | const x = new Map(); 97 | const y = new Map(testMap); 98 | y.forEach((val, key) => { 99 | x.set(val, key); 100 | }); 101 | }) 102 | .add('merge: object', () => { 103 | const _1 = new Map(); 104 | const _2 = new Map(testMap); 105 | 106 | const x = {}; 107 | const y = { ...testObj }; 108 | Object.assign(x, y); 109 | }) 110 | .on('cycle', function (event) { 111 | console.log(event.target); 112 | }) 113 | .on('complete', function () { 114 | console.log('Fastest is ' + this.filter('fastest').map('name')); 115 | }) 116 | .run({ 'async': true }); 117 | 118 | (new Suite('map vs object - delete')) 119 | .add('delete: map', () => { 120 | const _ = { ...testObj }; 121 | const x = new Map(testMap); 122 | x.delete('a'); 123 | }) 124 | .add('delete: object', () => { 125 | const x = { ...testObj }; 126 | const _ = new Map(testMap); 127 | // @ts-ignore 128 | delete x['a']; 129 | }) 130 | .on('cycle', function (event) { 131 | console.log(event.target); 132 | }) 133 | .on('complete', function () { 134 | console.log('Fastest is ' + this.filter('fastest').map('name')); 135 | }) 136 | .run({ 'async': true }); 137 | 138 | (new Suite('map vs object - set')) 139 | .add('set: map', () => { 140 | const _ = {}; 141 | const x = new Map(); 142 | x.set('a', 1); 143 | }) 144 | .add('set: object', () => { 145 | const x = {} as any; 146 | const _ = new Map(); 147 | x['a'] = 1; 148 | }) 149 | .on('cycle', function (event) { 150 | console.log(event.target); 151 | }) 152 | .on('complete', function () { 153 | console.log('Fastest is ' + this.filter('fastest').map('name')); 154 | }) 155 | .run({ 'async': true }); 156 | -------------------------------------------------------------------------------- /src/style-attrs.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | export const enum StyleAttrsKeys { 16 | BackgroundColor, 17 | Color, 18 | FontFamily, 19 | FontSize, 20 | FontStyle, 21 | FontWeight, 22 | Opacity, 23 | Outline, 24 | TextDecoration, 25 | TextDecorationColor, 26 | TextDecorationLine, 27 | TextDecorationStyle, 28 | VerticalAlign, 29 | Visibility, 30 | } 31 | 32 | const numKeys = 14; 33 | 34 | const nameMap = new Array(numKeys); 35 | nameMap[StyleAttrsKeys.BackgroundColor] = 'background-color'; 36 | nameMap[StyleAttrsKeys.Color] = 'color'; 37 | nameMap[StyleAttrsKeys.FontFamily] = 'font-family'; 38 | nameMap[StyleAttrsKeys.FontSize] = 'font-size'; 39 | nameMap[StyleAttrsKeys.FontStyle] = 'font-style'; 40 | nameMap[StyleAttrsKeys.FontWeight] = 'font-weight'; 41 | nameMap[StyleAttrsKeys.Opacity] = 'opacity'; 42 | nameMap[StyleAttrsKeys.Outline] = 'outline'; 43 | nameMap[StyleAttrsKeys.TextDecoration] = 'text-decoration'; 44 | nameMap[StyleAttrsKeys.TextDecorationColor] = 'text-decoration-color'; 45 | nameMap[StyleAttrsKeys.TextDecorationLine] = 'text-decoration-line'; 46 | nameMap[StyleAttrsKeys.TextDecorationStyle] = 'text-decoration-style'; 47 | nameMap[StyleAttrsKeys.VerticalAlign] = 'vertical-align'; 48 | nameMap[StyleAttrsKeys.Visibility] = 'visibility'; 49 | 50 | /** 51 | * Represents a map between style attribute keys and string values 52 | */ 53 | export type StyleAttrsDict = { [key in StyleAttrsKeys]?: string }; 54 | 55 | /** 56 | * Represents a function that modifies style attributes 57 | */ 58 | export type StyleAttrsModifierFn = (attrs: StyleAttrs) => void; 59 | 60 | /** 61 | * Represents style attributes holder 62 | */ 63 | export class StyleAttrs { 64 | private attrArray = new Array(numKeys); 65 | 66 | private _size = 0; 67 | 68 | /** 69 | * Get attr 70 | * @param {StyleAttrKeys} key 71 | */ 72 | public get(key: StyleAttrsKeys) { 73 | return this.attrArray[key]; 74 | } 75 | 76 | /** 77 | * 78 | * @param {StyleAttrsKeys} key 79 | * @param {string} value 80 | */ 81 | public set(key: StyleAttrsKeys, value: string) { 82 | const oldValue = this.attrArray[key]; 83 | this.attrArray[key] = value; 84 | if (oldValue === undefined && value !== undefined) this._size += 1; 85 | } 86 | 87 | /** 88 | * Delete attrs 89 | * @param {StyleAttrKeys} key 90 | */ 91 | public delete(key: StyleAttrsKeys) { 92 | const value = this.attrArray[key]; 93 | this.attrArray[key] = undefined; 94 | if (value !== undefined) this._size -= 1; 95 | } 96 | 97 | /** 98 | * Merge values from another object into this one 99 | * @param {StyleAttrs} attrs - The attributes to merge into instance 100 | */ 101 | public update(attrs: StyleAttrsDict) { 102 | for (const key in attrs) { 103 | if (attrs.hasOwnProperty(key)) { 104 | // @ts-expect-error key type is string 105 | const value = attrs[key]; 106 | // @ts-expect-error key type is string 107 | this.attrArray[key] = value; 108 | if (value !== undefined) this._size += 1; 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * Delete all style attributes 115 | */ 116 | public clear() { 117 | this.attrArray.fill(undefined); 118 | this._size = 0; 119 | } 120 | 121 | /** 122 | * Get number of non-empty attributes 123 | * @returns {number} Number of non-empty attributes 124 | */ 125 | get size(): number { 126 | return this._size; 127 | } 128 | 129 | /** 130 | * Convert object to string for insertion to `style` elelment attribute 131 | * @returns {string} Semi-colon separated list of key:val pairs 132 | */ 133 | public toString(): string { 134 | let buffer = ''; 135 | this.attrArray.forEach((value, i) => { 136 | if (value === undefined) return; 137 | buffer += `${nameMap[i]}:${value};`; 138 | }); 139 | return buffer; 140 | } 141 | 142 | /** 143 | * Remove attributes at `keys` if present 144 | * @param {StyleAttrsKeys} key 145 | * @returns {StyleAttrsModifierFn} Modifier function 146 | */ 147 | static delete(...keys: StyleAttrsKeys[]): StyleAttrsModifierFn { 148 | return (attrs) => { 149 | keys.forEach((key) => { 150 | attrs.delete(key); 151 | }); 152 | }; 153 | } 154 | 155 | /** 156 | * Append `value` to space separated list if not present 157 | * @param {StyleAttrsKeys} key 158 | * @param {string} value 159 | * @returns {StyleAttrsModifierFn} Modifier function 160 | */ 161 | static appendVal(key: StyleAttrsKeys, value: string): StyleAttrsModifierFn { 162 | return (attrs) => { 163 | const currVal = attrs.get(key); 164 | const vals = (currVal) ? currVal.split(' ') : []; 165 | if (!vals.includes(value)) vals.push(value); 166 | attrs.set(key, vals.join(' ')); 167 | }; 168 | } 169 | 170 | /** 171 | * Remove `value` from space separated list if present 172 | * @param {StyleAttrsKeys} key 173 | * @param {string} val 174 | * @returns {StyleAttrsModifierFn} Modifier function 175 | */ 176 | static removeVal(key: StyleAttrsKeys, value: string): StyleAttrsModifierFn { 177 | return (attrs) => { 178 | const currVal = attrs.get(key); 179 | let vals = (currVal) ? currVal.split(' ') : []; 180 | vals = vals.filter((x) => x !== value); 181 | if (!vals.length) attrs.delete(key); 182 | else attrs.set(key, vals.join(' ')); 183 | }; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /hack/App.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { useState } from 'react'; 16 | 17 | import { AnsiHtml } from '@/react'; 18 | 19 | const sgrParameters: [number, string, string?][] = [ 20 | [0, 'Reset'], 21 | [1, 'Bold'], 22 | [2, 'Dim'], 23 | [3, 'Italic'], 24 | [4, 'Underline'], 25 | [8, 'Hide', '|\x1b[8mConceal or hide\x1b[0m|'], 26 | [9, 'Strikethrough'], 27 | [10, 'Default font', '\x1b[11mOn\x1b[10m Off\x1b[0m'], 28 | [11, 'Alternative font 1'], 29 | [12, 'Alternative font 2'], 30 | [13, 'Alternative font 3'], 31 | [14, 'Alternative font 4'], 32 | [15, 'Alternative font 5'], 33 | [16, 'Alternative font 6'], 34 | [17, 'Alternative font 7'], 35 | [18, 'Alternative font 8'], 36 | [19, 'Alternative font 9'], 37 | [21, 'Double underline'], 38 | [22, 'Bold off', '\x1b[1mOn \x1b[22moff\x1b[0m'], 39 | [23, 'Italic off', '\x1b[3mOn \x1b[23moff\x1b[0m'], 40 | [24, 'Underline off', '\x1b[4mOn\x1b[24m Off\x1b[0m'], 41 | [28, 'Hidden off', '|\x1b[8mOn\x1b[28m| |Off|\x1b[0m'], 42 | [29, 'Strikethrough off', '\x1b[9mOn\x1b[29m Off\x1b[0m'], 43 | [30, 'Foreground color - black'], 44 | [31, 'Foreground color - red'], 45 | [32, 'Foreground color - green'], 46 | [33, 'Foreground color - yellow'], 47 | [34, 'Foreground color - blue'], 48 | [35, 'Foreground color - magenta'], 49 | [36, 'Foreground color - cyan'], 50 | [37, 'Foreground color - white'], 51 | [38, 'Foreground color - extended (see below)'], 52 | [39, 'Default foreground color', '\x1b[31mOn\x1b[39m Off\x1b[0m'], 53 | [40, 'Background color - black'], 54 | [41, 'Background color - red'], 55 | [42, 'Background color - green'], 56 | [43, 'Background color - yellow'], 57 | [44, 'Background color - blue'], 58 | [45, 'Background color - magenta'], 59 | [46, 'Background color - cyan'], 60 | [47, 'Background color - white'], 61 | [48, 'Background color - extended (see below)'], 62 | [49, 'Default background color', '\x1b[41mOn\x1b[49m Off\x1b[0m'], 63 | [51, 'Frame'], 64 | [53, 'Overline'], 65 | [54, 'Frame or encircle off', '\x1b[51mOn\x1b[54m Off\x1b[0m'], 66 | [55, 'Overline off', '\x1b[53mOn\x1b[55m Off\x1b[0m'], 67 | [58, 'Set underline color (see below)'], 68 | [59, 'Default underline color', '\x1b[4m\x1b[58;5;1mOn\x1b[59mOff\x1b[0m'], 69 | [73, 'Superscript', 'Super\x1b[73mscript\x1b[0m'], 70 | [74, 'Subscript', 'Sub\x1b[74mscript\x1b[0m'], 71 | [75, 'Superscript/subscript off', 'Super\x1b[73mon\x1b[75moff\x1b[0m, Sub\x1b[74mon\x1b[75moff\x1b[0m'], 72 | [90, 'Foreground color - bright black'], 73 | [91, 'Foreground color - bright red'], 74 | [92, 'Foreground color - bright green'], 75 | [93, 'Foreground color - bright yellow'], 76 | [94, 'Foreground color - bright blue'], 77 | [95, 'Foreground color - bright magenta'], 78 | [96, 'Foreground color - bright cyan'], 79 | [97, 'Foreground color - bright white'], 80 | [100, 'Background color - bright black'], 81 | [101, 'Background color - bright red'], 82 | [102, 'Background color - bright green'], 83 | [103, 'Background color - bright yellow'], 84 | [104, 'Background color - bright blue'], 85 | [105, 'Background color - bright magenta'], 86 | [106, 'Background color - bright cyan'], 87 | [107, 'Background color - bright white'], 88 | ]; 89 | 90 | const DisplayRawInput = ({ data }: { data: string }) => ( 91 | <> 92 | {data.replaceAll('\x1b', '\\x1b')} 93 | navigator.clipboard.writeText(data)} 96 | > 97 | copy 98 | 99 | 100 | ); 101 | 102 | function App() { 103 | const [testStr, setTestStr] = useState(); 104 | 105 | const handleThemeChange = (ev: React.ChangeEvent) => { 106 | if (ev.target.value === 'dark') document.documentElement.classList.add('dark'); 107 | else document.documentElement.classList.remove('dark'); 108 | }; 109 | 110 | const handleTestChange = (ev: React.ChangeEvent) => { 111 | setTestStr(ev.target.value); 112 | }; 113 | 114 | return ( 115 |
116 |
117 | 118 | 122 |
123 |
124 |
125 | 126 |
127 | 128 |
129 |
130 |
Supported SGR parameters:
131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | {sgrParameters.map(([n, name, example]) => { 142 | const exampleStr = example || `\x1b[${n}m${name}\x1b[0m`; 143 | return ( 144 | 145 | 146 | 147 | 148 | 149 | 150 | ); 151 | })} 152 | 153 |
nNameRaw InputExample Output
{n}{name}
154 |
Extended Color Palette (0-255):
155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | {Array.from({ length: 256 }, (_, i) => i).map(i => { 175 | const exampleStrFg = `\x1b[38;5;${i}mColor Code - ${i}\x1b[0m`; 176 | const exampleStrBg = `\x1b[48;5;${i}mColor Code - ${i}\x1b[0m`; 177 | const exampleStrUl = `\x1b[4m\x1b[58;5;${i}mColor Code - ${i}\x1b[0m`; 178 | 179 | return ( 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | ) 190 | })} 191 | 192 |
 ForegroundBackgroundUnderline
Color CodeRaw InputExample OutputRaw InputExample OutputRaw InputExample Output
{i}
193 |
194 | ) 195 | } 196 | 197 | export default App; 198 | -------------------------------------------------------------------------------- /src/colors.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | export const enum StandardColorKeys { 16 | Black = 'black', 17 | Red = 'red', 18 | Green = 'green', 19 | Yellow = 'yellow', 20 | Blue = 'blue', 21 | Magenta = 'magenta', 22 | Cyan = 'cyan', 23 | White = 'white', 24 | BrightBlack = 'bright-black', 25 | BrightRed = 'bright-red', 26 | BrightGreen = 'bright-green', 27 | BrightYellow = 'bright-yellow', 28 | BrightBlue = 'bright-blue', 29 | BrightMagenta = 'bright-magenta', 30 | BrightCyan = 'bright-cyan', 31 | BrightWhite = 'bright-white', 32 | } 33 | 34 | // https://github.com/xtermjs/xterm.js/blob/53bc9f8442994d75ad522a117a0c2ffd78e50781/src/browser/services/ThemeService.ts#L88 35 | export const xtermjs = { 36 | [StandardColorKeys.Black]: '#2e3436', 37 | [StandardColorKeys.Red]: '#cc0000', 38 | [StandardColorKeys.Green]: '#4e9a06', 39 | [StandardColorKeys.Yellow]: '#c4a000', 40 | [StandardColorKeys.Blue]: '#3465a4', 41 | [StandardColorKeys.Magenta]: '#75507b', 42 | [StandardColorKeys.Cyan]: '#06989a', 43 | [StandardColorKeys.White]: '#d3d7cf', 44 | [StandardColorKeys.BrightBlack]: '#555753', 45 | [StandardColorKeys.BrightRed]: '#ef2929', 46 | [StandardColorKeys.BrightGreen]: '#8ae234', 47 | [StandardColorKeys.BrightYellow]: '#fce94f', 48 | [StandardColorKeys.BrightBlue]: '#729fcf', 49 | [StandardColorKeys.BrightMagenta]: '#ad7fa8', 50 | [StandardColorKeys.BrightCyan]: '#34e2e2', 51 | [StandardColorKeys.BrightWhite]: '#eeeeec', 52 | }; 53 | 54 | // https://en.wikipedia.org/wiki/ANSI_escape_code 55 | export const vga = { 56 | [StandardColorKeys.Black]: '#000000', 57 | [StandardColorKeys.Red]: '#aa0000', 58 | [StandardColorKeys.Green]: '#00aa00', 59 | [StandardColorKeys.Yellow]: '#aa5500', 60 | [StandardColorKeys.Blue]: '#0000aa', 61 | [StandardColorKeys.Magenta]: '#aa00aa', 62 | [StandardColorKeys.Cyan]: '#00aaaa', 63 | [StandardColorKeys.White]: '#aaaaaa', 64 | [StandardColorKeys.BrightBlack]: '#555555', 65 | [StandardColorKeys.BrightRed]: '#ff5555', 66 | [StandardColorKeys.BrightGreen]: '#55ff55', 67 | [StandardColorKeys.BrightYellow]: '#ffff55', 68 | [StandardColorKeys.BrightBlue]: '#5555ff', 69 | [StandardColorKeys.BrightMagenta]: '#ff55ff', 70 | [StandardColorKeys.BrightCyan]: '#55ffff', 71 | [StandardColorKeys.BrightWhite]: '#ffffff', 72 | }; 73 | 74 | // https://en.wikipedia.org/wiki/ANSI_escape_code 75 | export const vscode = { 76 | [StandardColorKeys.Black]: '#000000', 77 | [StandardColorKeys.Red]: '#cd3131', 78 | [StandardColorKeys.Green]: '#0dbc79', 79 | [StandardColorKeys.Yellow]: '#e5e510', 80 | [StandardColorKeys.Blue]: '#2473c8', 81 | [StandardColorKeys.Magenta]: '#bc3fbc', 82 | [StandardColorKeys.Cyan]: '#11a7cd', 83 | [StandardColorKeys.White]: '#e5e5e5', 84 | [StandardColorKeys.BrightBlack]: '#666666', 85 | [StandardColorKeys.BrightRed]: '#f14c4c', 86 | [StandardColorKeys.BrightGreen]: '#23d18b', 87 | [StandardColorKeys.BrightYellow]: '#f5f5430', 88 | [StandardColorKeys.BrightBlue]: '#3b8dea', 89 | [StandardColorKeys.BrightMagenta]: '#d670d6', 90 | [StandardColorKeys.BrightCyan]: '#29b7db', 91 | [StandardColorKeys.BrightWhite]: '#e5e5e5', 92 | }; 93 | 94 | // https://en.wikipedia.org/wiki/ANSI_escape_code 95 | export const windows10 = { 96 | [StandardColorKeys.Black]: '#0c0c0c', 97 | [StandardColorKeys.Red]: '#c50f1e', 98 | [StandardColorKeys.Green]: '#13a10e', 99 | [StandardColorKeys.Yellow]: '#c19a00', 100 | [StandardColorKeys.Blue]: '#0037da', 101 | [StandardColorKeys.Magenta]: '#891798', 102 | [StandardColorKeys.Cyan]: '#3a96dd', 103 | [StandardColorKeys.White]: '#cccccc', 104 | [StandardColorKeys.BrightBlack]: '#767676', 105 | [StandardColorKeys.BrightRed]: '#e74855', 106 | [StandardColorKeys.BrightGreen]: '#15c60c', 107 | [StandardColorKeys.BrightYellow]: '#f9f1a5', 108 | [StandardColorKeys.BrightBlue]: '#3b79ff', 109 | [StandardColorKeys.BrightMagenta]: '#b4009f', 110 | [StandardColorKeys.BrightCyan]: '#61d6d6', 111 | [StandardColorKeys.BrightWhite]: '#f2f2f2', 112 | }; 113 | 114 | // https://en.wikipedia.org/wiki/ANSI_escape_code 115 | export const terminalapp = { 116 | [StandardColorKeys.Black]: '#000000', 117 | [StandardColorKeys.Red]: '#990000', 118 | [StandardColorKeys.Green]: '#00a600', 119 | [StandardColorKeys.Yellow]: '#999900', 120 | [StandardColorKeys.Blue]: '#0000b2', 121 | [StandardColorKeys.Magenta]: '#b200b2', 122 | [StandardColorKeys.Cyan]: '#00a6b2', 123 | [StandardColorKeys.White]: '#bfbfbf', 124 | [StandardColorKeys.BrightBlack]: '#666666', 125 | [StandardColorKeys.BrightRed]: '#e60000', 126 | [StandardColorKeys.BrightGreen]: '#00d900', 127 | [StandardColorKeys.BrightYellow]: '#e6e600', 128 | [StandardColorKeys.BrightBlue]: '#0000ff', 129 | [StandardColorKeys.BrightMagenta]: '#e600e6', 130 | [StandardColorKeys.BrightCyan]: '#00e6e6', 131 | [StandardColorKeys.BrightWhite]: '#e6e6e6', 132 | }; 133 | 134 | // https://en.wikipedia.org/wiki/ANSI_escape_code 135 | export const putty = { 136 | [StandardColorKeys.Black]: '#000000', 137 | [StandardColorKeys.Red]: '#bb0000', 138 | [StandardColorKeys.Green]: '#00bb00', 139 | [StandardColorKeys.Yellow]: '#bbbb00', 140 | [StandardColorKeys.Blue]: '#0000bb', 141 | [StandardColorKeys.Magenta]: '#bb00bb', 142 | [StandardColorKeys.Cyan]: '#00bbbb', 143 | [StandardColorKeys.White]: '#bbbbbb', 144 | [StandardColorKeys.BrightBlack]: '#555555', 145 | [StandardColorKeys.BrightRed]: '#ff5555', 146 | [StandardColorKeys.BrightGreen]: '#55ff55', 147 | [StandardColorKeys.BrightYellow]: '#ffff55', 148 | [StandardColorKeys.BrightBlue]: '#5555ff', 149 | [StandardColorKeys.BrightMagenta]: '#ff55ff', 150 | [StandardColorKeys.BrightCyan]: '#55ffff', 151 | [StandardColorKeys.BrightWhite]: '#ffffff', 152 | }; 153 | 154 | // https://en.wikipedia.org/wiki/ANSI_escape_code 155 | export const xterm = { 156 | [StandardColorKeys.Black]: '#000000', 157 | [StandardColorKeys.Red]: '#cd0000', 158 | [StandardColorKeys.Green]: '#00cd00', 159 | [StandardColorKeys.Yellow]: '#cdcd00', 160 | [StandardColorKeys.Blue]: '#0000ee', 161 | [StandardColorKeys.Magenta]: '#cd00cd', 162 | [StandardColorKeys.Cyan]: '#00cdcd', 163 | [StandardColorKeys.White]: '#e5e5e5', 164 | [StandardColorKeys.BrightBlack]: '#7f7f7f', 165 | [StandardColorKeys.BrightRed]: '#ff0000', 166 | [StandardColorKeys.BrightGreen]: '#00ff00', 167 | [StandardColorKeys.BrightYellow]: '#ffff00', 168 | [StandardColorKeys.BrightBlue]: '#5c5cff', 169 | [StandardColorKeys.BrightMagenta]: '#ff00ff', 170 | [StandardColorKeys.BrightCyan]: '#00ffff', 171 | [StandardColorKeys.BrightWhite]: '#ffffff', 172 | }; 173 | 174 | // https://en.wikipedia.org/wiki/ANSI_escape_code 175 | export const ubuntu = { 176 | [StandardColorKeys.Black]: '#010101', 177 | [StandardColorKeys.Red]: '#de382b', 178 | [StandardColorKeys.Green]: '#39b54a', 179 | [StandardColorKeys.Yellow]: '#ffc906', 180 | [StandardColorKeys.Blue]: '#006eb8', 181 | [StandardColorKeys.Magenta]: '#762671', 182 | [StandardColorKeys.Cyan]: '#2cb3e9', 183 | [StandardColorKeys.White]: '#cccccc', 184 | [StandardColorKeys.BrightBlack]: '#808080', 185 | [StandardColorKeys.BrightRed]: '#ff0000', 186 | [StandardColorKeys.BrightGreen]: '#00ff00', 187 | [StandardColorKeys.BrightYellow]: '#ffff00', 188 | [StandardColorKeys.BrightBlue]: '#0000ff', 189 | [StandardColorKeys.BrightMagenta]: '#ff00ff', 190 | [StandardColorKeys.BrightCyan]: '#00ffff', 191 | [StandardColorKeys.BrightWhite]: '#ffffff', 192 | }; 193 | 194 | // https://en.wikipedia.org/wiki/ANSI_escape_code 195 | export const eclipse = { 196 | [StandardColorKeys.Black]: '#000000', 197 | [StandardColorKeys.Red]: '#cd0000', 198 | [StandardColorKeys.Green]: '#00cd00', 199 | [StandardColorKeys.Yellow]: '#cdcd00', 200 | [StandardColorKeys.Blue]: '#0000ee', 201 | [StandardColorKeys.Magenta]: '#cd00cd', 202 | [StandardColorKeys.Cyan]: '#00cdcd', 203 | [StandardColorKeys.White]: '#e5e5e5', 204 | [StandardColorKeys.BrightBlack]: '#000000', 205 | [StandardColorKeys.BrightRed]: '#ff0000', 206 | [StandardColorKeys.BrightGreen]: '#00ff00', 207 | [StandardColorKeys.BrightYellow]: '#ffff00', 208 | [StandardColorKeys.BrightBlue]: '#5c5cff', 209 | [StandardColorKeys.BrightMagenta]: '#ff00ff', 210 | [StandardColorKeys.BrightCyan]: '#00ffff', 211 | [StandardColorKeys.BrightWhite]: '#ffffff', 212 | }; 213 | -------------------------------------------------------------------------------- /src/style-attrs.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import { StyleAttrs, StyleAttrsKeys } from './style-attrs'; 16 | 17 | describe('StyleAttrs', () => { 18 | describe('instance methods', () => { 19 | describe('.get()', () => { 20 | it('returns undefined when attr not present', () => { 21 | const attrs = new StyleAttrs(); 22 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual(undefined); 23 | }); 24 | 25 | it('returns value when attr present', () => { 26 | const attrs = new StyleAttrs(); 27 | attrs.set(StyleAttrsKeys.Color, 'red'); 28 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 29 | }); 30 | }); 31 | 32 | describe('.set()', () => { 33 | it('sets value and updates size on new attributes', () => { 34 | const attrs = new StyleAttrs(); 35 | attrs.set(StyleAttrsKeys.Color, 'red'); 36 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 37 | expect(attrs.size).toEqual(1); 38 | }); 39 | 40 | it('doesnt set value or updates size when input is undefined', () => { 41 | const attrs = new StyleAttrs(); 42 | // @ts-ignore 43 | attrs.set(StyleAttrsKeys.Color, undefined); 44 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual(undefined); 45 | expect(attrs.size).toEqual(0); 46 | }); 47 | 48 | it('sets value but doesnt update size input is already defined', () => { 49 | const attrs = new StyleAttrs(); 50 | 51 | attrs.set(StyleAttrsKeys.Color, 'red'); 52 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 53 | expect(attrs.size).toEqual(1); 54 | 55 | attrs.set(StyleAttrsKeys.Color, 'green'); 56 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('green'); 57 | expect(attrs.size).toEqual(1); 58 | }); 59 | }); 60 | 61 | describe('.delete()', () => { 62 | it('doesnt change value or size when attribute not present', () => { 63 | const attrs = new StyleAttrs(); 64 | attrs.delete(StyleAttrsKeys.Color); 65 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual(undefined); 66 | expect(attrs.size).toEqual(0); 67 | }); 68 | 69 | it('deletes value and updates size when attribute is present', () => { 70 | const attrs = new StyleAttrs(); 71 | 72 | attrs.set(StyleAttrsKeys.Color, 'red'); 73 | expect(attrs.size).toEqual(1); 74 | 75 | attrs.delete(StyleAttrsKeys.Color); 76 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual(undefined); 77 | expect(attrs.size).toEqual(0); 78 | }); 79 | }); 80 | 81 | describe('.update()', () => { 82 | it('sets values and updates size on one new attributes', () => { 83 | const attrs = new StyleAttrs(); 84 | attrs.update({ 85 | [StyleAttrsKeys.Color]: 'red', 86 | }); 87 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 88 | expect(attrs.size).toEqual(1); 89 | }); 90 | 91 | it('sets values and updates size on multiple new attributes', () => { 92 | const attrs = new StyleAttrs(); 93 | attrs.update({ 94 | [StyleAttrsKeys.Color]: 'red', 95 | [StyleAttrsKeys.BackgroundColor]: 'green', 96 | }); 97 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 98 | expect(attrs.get(StyleAttrsKeys.BackgroundColor)).toEqual('green'); 99 | expect(attrs.size).toEqual(2); 100 | }); 101 | 102 | it('ignores values that are undefined', () => { 103 | const attrs = new StyleAttrs(); 104 | attrs.update({ 105 | [StyleAttrsKeys.Color]: 'red', 106 | [StyleAttrsKeys.BackgroundColor]: undefined, 107 | }); 108 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 109 | expect(attrs.get(StyleAttrsKeys.BackgroundColor)).toEqual(undefined); 110 | expect(attrs.size).toEqual(1); 111 | }); 112 | }); 113 | 114 | describe('.clear()', () => { 115 | it('resets values and size when called', () => { 116 | const attrs = new StyleAttrs(); 117 | attrs.update({ 118 | [StyleAttrsKeys.Color]: 'red', 119 | [StyleAttrsKeys.BackgroundColor]: 'green', 120 | }); 121 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 122 | expect(attrs.get(StyleAttrsKeys.BackgroundColor)).toEqual('green'); 123 | expect(attrs.size).toEqual(2); 124 | 125 | attrs.clear(); 126 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual(undefined); 127 | expect(attrs.get(StyleAttrsKeys.BackgroundColor)).toEqual(undefined); 128 | expect(attrs.size).toEqual(0); 129 | }); 130 | }); 131 | 132 | describe('.toString()', () => { 133 | it('returns emtpy string when object is empty', () => { 134 | const attrs = new StyleAttrs(); 135 | expect(attrs.toString()).toEqual(''); 136 | }); 137 | 138 | it('returns stringified attribute when one is present', () => { 139 | const attrs = new StyleAttrs(); 140 | attrs.set(StyleAttrsKeys.Color, 'red'); 141 | expect(attrs.toString()).toEqual('color:red;'); 142 | }); 143 | 144 | it('returns stringified attributes when multiple are present', () => { 145 | const attrs = new StyleAttrs(); 146 | attrs.set(StyleAttrsKeys.Color, 'red'); 147 | attrs.set(StyleAttrsKeys.BackgroundColor, 'green'); 148 | expect(attrs.toString()).toEqual('background-color:green;color:red;'); 149 | }); 150 | }); 151 | }); 152 | 153 | describe('class methods', () => { 154 | describe('.delete()', () => { 155 | it('returns attribute modifier that deletes attributes', () => { 156 | const attrs = new StyleAttrs(); 157 | attrs.set(StyleAttrsKeys.Color, 'red'); 158 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 159 | expect(attrs.size).toEqual(1); 160 | 161 | const fn = StyleAttrs.delete(StyleAttrsKeys.Color); 162 | fn(attrs); 163 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual(undefined); 164 | expect(attrs.size).toEqual(0); 165 | }); 166 | 167 | it('supports deletion of multiple keys', () => { 168 | const attrs = new StyleAttrs(); 169 | attrs.set(StyleAttrsKeys.Color, 'red'); 170 | attrs.set(StyleAttrsKeys.BackgroundColor, 'green'); 171 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 172 | expect(attrs.get(StyleAttrsKeys.BackgroundColor)).toEqual('green'); 173 | expect(attrs.size).toEqual(2); 174 | 175 | const fn = StyleAttrs.delete(StyleAttrsKeys.Color, StyleAttrsKeys.BackgroundColor); 176 | fn(attrs); 177 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual(undefined); 178 | expect(attrs.get(StyleAttrsKeys.BackgroundColor)).toEqual(undefined); 179 | expect(attrs.size).toEqual(0); 180 | }); 181 | 182 | }); 183 | 184 | describe('.appendVal()', () => { 185 | it('returns attribute modifier that sets attribute when empty', () => { 186 | const attrs = new StyleAttrs(); 187 | const fn = StyleAttrs.appendVal(StyleAttrsKeys.Color, 'red'); 188 | fn(attrs); 189 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red'); 190 | }); 191 | 192 | it('returns attribute modifier that appends attribute when present', () => { 193 | const attrs = new StyleAttrs(); 194 | attrs.set(StyleAttrsKeys.Color, 'red'); 195 | 196 | const fn = StyleAttrs.appendVal(StyleAttrsKeys.Color, 'green'); 197 | fn(attrs); 198 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('red green'); 199 | }); 200 | }); 201 | 202 | describe('.removeVal()', () => { 203 | it('returns attribute modifier that does nothing when attribute is empty', () => { 204 | const attrs = new StyleAttrs(); 205 | const fn = StyleAttrs.removeVal(StyleAttrsKeys.Color, 'red'); 206 | fn(attrs); 207 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual(undefined); 208 | }); 209 | 210 | it('returns attribute modifier that clears attributes when only value is removed', () => { 211 | const attrs = new StyleAttrs(); 212 | attrs.set(StyleAttrsKeys.Color, 'red'); 213 | 214 | const fn = StyleAttrs.removeVal(StyleAttrsKeys.Color, 'red'); 215 | fn(attrs); 216 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual(undefined); 217 | }); 218 | 219 | it('returns attribute modifier that removes attribute when multiple are present', () => { 220 | const attrs = new StyleAttrs(); 221 | attrs.set(StyleAttrsKeys.Color, 'red green'); 222 | 223 | const fn = StyleAttrs.removeVal(StyleAttrsKeys.Color, 'red'); 224 | fn(attrs); 225 | expect(attrs.get(StyleAttrsKeys.Color)).toEqual('green'); 226 | }); 227 | }); 228 | }); 229 | }); 230 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // The regexes and much of the parsing code is from ansi_up: https://github.com/drudru/ansi_up/blob/main/ansi_up.ts 16 | 17 | /* ansi_up.js 18 | * author : Dru Nelson 19 | * license : MIT 20 | * http://github.com/drudru/ansi_up 21 | */ 22 | 23 | /* 24 | const csiRegex = rgx` 25 | ^ # beginning of line 26 | # 27 | # First attempt 28 | (?: # legal sequence 29 | \x1b\[ # CSI 30 | ([\x3c-\x3f]?) # private-mode char 31 | ([\d;]*) # any digits or semicolons 32 | ([\x20-\x2f]? # an intermediate modifier 33 | [\x40-\x7e]) # the command 34 | ) 35 | | # alternate (second attempt) 36 | (?: # illegal sequence 37 | \x1b\[ # CSI 38 | [\x20-\x7e]* # anything legal 39 | ([\x00-\x1f:]) # anything illegal 40 | ) 41 | `; 42 | 43 | const oscRegex = rgx` 44 | ^ # beginning of line 45 | # 46 | \x1b\]8; # OSC Hyperlink 47 | [\x20-\x3a\x3c-\x7e]* # params (excluding ;) 48 | ; # end of params 49 | ([\x21-\x7e]{0,512}) # URL capture 50 | (?: # ST 51 | (?:\x1b\\) # ESC \ 52 | | # alternate 53 | (?:\x07) # BEL (what xterm did) 54 | ) 55 | ([\x20-\x7e]+) # TEXT capture 56 | \x1b\]8;; # OSC Hyperlink End 57 | (?: # ST 58 | (?:\x1b\\) # ESC \ 59 | | # alternate 60 | (?:\x07) # BEL (what xterm did) 61 | ) 62 | `; 63 | 64 | const oscTerminatorRegex = rgxG` 65 | (?: # legal sequence 66 | (\x1b\\) # ESC \ 67 | | # alternate 68 | (\x07) # BEL (what xterm did) 69 | ) 70 | | # alternate (second attempt) 71 | ( # illegal sequence 72 | [\x00-\x06] # anything illegal 73 | | # alternate 74 | [\x08-\x1a] # anything illegal 75 | | # alternate 76 | [\x1c-\x1f] # anything illegal 77 | ) 78 | `; 79 | */ 80 | 81 | const csiRegex = /^(?:\x1b\[([\x3c-\x3f]?)([\d;]*)([\x20-\x2f]?[\x40-\x7e]))|(?:\x1b\[[\x20-\x7e]*([\x00-\x1f:]))/; 82 | const oscRegex = /^\x1b\]8;[\x20-\x3a\x3c-\x7e]*;([\x21-\x7e]{0,512})(?:(?:\x1b\\)|(?:\x07))([\x20-\x7e]+)\x1b\]8;;(?:(?:\x1b\\)|(?:\x07))/; 83 | const oscTerminatorRegex = /(?:(\x1b\\)|(\x07))|([\x00-\x06]|[\x08-\x1a]|[\x1c-\x1f])/g; 84 | 85 | /** 86 | * Represents the different kinds of packets that can be parsed 87 | */ 88 | export const enum PacketKind { 89 | UNSET, 90 | Text, 91 | ESC, // A single ESC char - random 92 | Unknown, // A valid CSI but not an SGR code 93 | SGR, // Select Graphic Rendition 94 | OSCURL, // Operating System Command 95 | } 96 | 97 | /** 98 | * Represents a parsed packet 99 | */ 100 | export type Packet = { 101 | kind: PacketKind; 102 | text: string; 103 | url: string; 104 | }; 105 | 106 | /** 107 | * Represents an instance of a parser that implements the JavaScript iterator interface 108 | * @param {string} input - The string to parse 109 | */ 110 | export class Parser implements IterableIterator { 111 | private lastIndex = 0; 112 | 113 | private readonly input: string; 114 | 115 | constructor(input: string) { 116 | this.input = input; 117 | } 118 | 119 | /** 120 | * Get the next parsed packet 121 | * @returns {IteratorResult} The next parsed packet 122 | */ 123 | public next(): IteratorResult { 124 | const remaining = this.input.substring(this.lastIndex); 125 | const len = remaining.length; 126 | 127 | // close iterator 128 | if (len === 0) return { value: null, done: true }; 129 | 130 | const pkt = { kind: PacketKind.UNSET, text: '', url: '' }; 131 | const pos = remaining.indexOf('\x1b'); 132 | 133 | // most common case, no ESC codes 134 | if (pos === -1) { 135 | pkt.kind = PacketKind.Text; 136 | pkt.text = remaining; 137 | this.lastIndex += len; 138 | return { value: pkt, done: false }; 139 | } 140 | 141 | // escape code is further ahead 142 | if (pos > 0) { 143 | pkt.kind = PacketKind.Text; 144 | pkt.text = remaining.slice(0, pos); 145 | this.lastIndex += pos; 146 | return { value: pkt, done: false }; 147 | } 148 | 149 | if (len < 3) { 150 | // sequences need at least 3 so this means last escape is incomplete 151 | return { value: null, done: true }; 152 | } 153 | 154 | // handle escapes 155 | const nextChar = remaining.charAt(1); 156 | 157 | // single ESC 158 | if ((nextChar !== '[') && (nextChar !== ']') && (nextChar !== '(')) { 159 | pkt.kind = PacketKind.ESC; 160 | pkt.text = remaining[0]; 161 | this.lastIndex += 1; 162 | return { value: pkt, done: false }; 163 | } 164 | 165 | // handle SGR 166 | if (nextChar === '[') { 167 | const match = remaining.match(csiRegex); 168 | 169 | // This match is guaranteed to terminate (even on 170 | // invalid input). The key is to match on legal and 171 | // illegal sequences. 172 | // The first alternate matches everything legal and 173 | // the second matches everything illegal. 174 | // 175 | // If it doesn't match, then we have not received 176 | // either the full sequence or an illegal sequence. 177 | // If it does match, the presence of field 4 tells 178 | // us whether it was legal or illegal. 179 | 180 | if (match === null) return { value: null, done: true }; 181 | 182 | // match is an array 183 | // 0 - total match 184 | // 1 - private mode chars group 185 | // 2 - digits and semicolons group 186 | // 3 - command 187 | // 4 - illegal char 188 | 189 | if (match[4]) { 190 | // Illegal sequence, just remove the ESC 191 | pkt.kind = PacketKind.ESC; 192 | pkt.text = remaining[0]; 193 | this.lastIndex += 1; 194 | return { value: pkt, done: false }; 195 | } 196 | 197 | // If not a valid SGR, we don't handle 198 | if ((match[1] !== '') || (match[3] !== 'm')) { 199 | pkt.kind = PacketKind.Unknown; 200 | } else { 201 | pkt.kind = PacketKind.SGR; 202 | } 203 | 204 | pkt.text = match[2]; // Just the parameters 205 | this.lastIndex += match[0].length; 206 | return { value: pkt, done: false }; 207 | } 208 | 209 | // handle OSC 210 | if (nextChar === ']') { 211 | if (len < 4) return { value: null, done: true }; 212 | 213 | if (remaining.charAt(2) !== '8' || remaining.charAt(3) !== ';') { 214 | // This is not a match, so we'll just treat it as ESC 215 | pkt.kind = PacketKind.ESC; 216 | pkt.text = remaining[0]; 217 | this.lastIndex += 1; 218 | return { value: pkt, done: false }; 219 | } 220 | 221 | // We do a stateful regex match with exec. 222 | // If it matches, the regex can be used again to 223 | // find the next match. 224 | const regex = new RegExp(oscTerminatorRegex); 225 | 226 | { 227 | const match = regex.exec(remaining); 228 | 229 | if (match === null) return { value: null, done: true }; 230 | 231 | // If an illegal character was found, bail on the match 232 | if (match[3]) { 233 | // Illegal sequence, just remove the ESC 234 | pkt.kind = PacketKind.ESC; 235 | pkt.text = remaining[0]; 236 | this.lastIndex += 1; 237 | return { value: pkt, done: false }; 238 | } 239 | } 240 | 241 | // OK - we might have the prefix and URI 242 | // Lets start our search for the next ST 243 | // past this index 244 | 245 | { 246 | const match = regex.exec(remaining); 247 | 248 | if (match === null) return { value: null, done: true }; 249 | 250 | // If an illegal character was found, bail on the match 251 | if (match[3]) { 252 | // Illegal sequence, just remove the ESC 253 | pkt.kind = PacketKind.ESC; 254 | pkt.text = remaining[0]; 255 | this.lastIndex += 1; 256 | return { value: pkt, done: false }; 257 | } 258 | } 259 | 260 | // now we have a full match 261 | const match = remaining.match(oscRegex); 262 | 263 | if (match === null) { 264 | // Illegal sequence, just remove the ESC 265 | pkt.kind = PacketKind.ESC; 266 | pkt.text = remaining[0]; 267 | this.lastIndex += 1; 268 | return { value: pkt, done: false }; 269 | } 270 | 271 | // match is an array 272 | // 0 - total match 273 | // 1 - URL 274 | // 2 - Text 275 | 276 | // If a valid SGR 277 | pkt.kind = PacketKind.OSCURL; 278 | pkt.url = match[1]; 279 | pkt.text = match[2]; 280 | this.lastIndex += match[0].length; 281 | return { value: pkt, done: false }; 282 | } 283 | 284 | // Other ESC CHECK 285 | if (nextChar === '(') { 286 | // This specifies the character set, which 287 | // should just be ignored 288 | 289 | // We have at least 3, so drop the sequence 290 | pkt.kind = PacketKind.Unknown; 291 | this.lastIndex += 3; 292 | return { value: pkt, done: false }; 293 | } 294 | 295 | return { value: null, done: true }; 296 | } 297 | 298 | [Symbol.iterator](): IterableIterator { 299 | return this; 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Andres Morey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import escape from 'escape-html'; 16 | 17 | import AnsiRegex from '@/ansi-regex'; 18 | import { StandardColorKeys, xtermjs } from '@/colors'; 19 | import { Packet, PacketKind, Parser } from '@/parse'; 20 | import { StyleAttrs, StyleAttrsKeys } from '@/style-attrs'; 21 | import type { StyleAttrsDict, StyleAttrsModifierFn } from '@/style-attrs'; 22 | 23 | const ansiRegex = AnsiRegex(); 24 | const hasAnsiRegex = AnsiRegex({ onlyFirst: true }); 25 | 26 | const styleAttrsMap = new Array(108); 27 | styleAttrsMap[0] = { [StyleAttrsKeys.FontWeight]: 'var(--ansi-bold-font-weight, 600)' }; 28 | styleAttrsMap[1] = { [StyleAttrsKeys.FontWeight]: 'var(--ansi-bold-font-weight, 600)' }; 29 | styleAttrsMap[2] = { [StyleAttrsKeys.Opacity]: 'var(--ansi-dim-opacity, 0.7)' }; 30 | styleAttrsMap[3] = { [StyleAttrsKeys.FontStyle]: 'italic' }; 31 | styleAttrsMap[4] = StyleAttrs.appendVal(StyleAttrsKeys.TextDecoration, 'underline'); 32 | styleAttrsMap[8] = { [StyleAttrsKeys.Visibility]: 'hidden' }; 33 | styleAttrsMap[9] = StyleAttrs.appendVal(StyleAttrsKeys.TextDecoration, 'line-through'); 34 | styleAttrsMap[10] = StyleAttrs.delete(StyleAttrsKeys.FontFamily); 35 | styleAttrsMap[11] = { [StyleAttrsKeys.FontFamily]: 'var(--ansi-font-1)' }; 36 | styleAttrsMap[12] = { [StyleAttrsKeys.FontFamily]: 'var(--ansi-font-2)' }; 37 | styleAttrsMap[13] = { [StyleAttrsKeys.FontFamily]: 'var(--ansi-font-3)' }; 38 | styleAttrsMap[14] = { [StyleAttrsKeys.FontFamily]: 'var(--ansi-font-4)' }; 39 | styleAttrsMap[15] = { [StyleAttrsKeys.FontFamily]: 'var(--ansi-font-5)' }; 40 | styleAttrsMap[16] = { [StyleAttrsKeys.FontFamily]: 'var(--ansi-font-6)' }; 41 | styleAttrsMap[17] = { [StyleAttrsKeys.FontFamily]: 'var(--ansi-font-7)' }; 42 | styleAttrsMap[18] = { [StyleAttrsKeys.FontFamily]: 'var(--ansi-font-8)' }; 43 | styleAttrsMap[19] = { [StyleAttrsKeys.FontFamily]: 'var(--ansi-font-9)' }; 44 | styleAttrsMap[21] = { [StyleAttrsKeys.TextDecorationLine]: 'underline', [StyleAttrsKeys.TextDecorationStyle]: 'double' }; 45 | styleAttrsMap[22] = StyleAttrs.delete(StyleAttrsKeys.FontWeight); 46 | styleAttrsMap[23] = StyleAttrs.delete(StyleAttrsKeys.FontStyle); 47 | styleAttrsMap[24] = StyleAttrs.removeVal(StyleAttrsKeys.TextDecoration, 'underline'); 48 | styleAttrsMap[28] = StyleAttrs.delete(StyleAttrsKeys.Visibility); 49 | styleAttrsMap[29] = StyleAttrs.removeVal(StyleAttrsKeys.TextDecoration, 'line-through'); 50 | styleAttrsMap[30] = { [StyleAttrsKeys.Color]: `var(--ansi-black, ${xtermjs[StandardColorKeys.Black]})` }; 51 | styleAttrsMap[31] = { [StyleAttrsKeys.Color]: `var(--ansi-red, ${xtermjs[StandardColorKeys.Red]})` }; 52 | styleAttrsMap[32] = { [StyleAttrsKeys.Color]: `var(--ansi-green, ${xtermjs[StandardColorKeys.Green]})` }; 53 | styleAttrsMap[33] = { [StyleAttrsKeys.Color]: `var(--ansi-yellow, ${xtermjs[StandardColorKeys.Yellow]})` }; 54 | styleAttrsMap[34] = { [StyleAttrsKeys.Color]: `var(--ansi-blue, ${xtermjs[StandardColorKeys.Blue]})` }; 55 | styleAttrsMap[35] = { [StyleAttrsKeys.Color]: `var(--ansi-magenta, ${xtermjs[StandardColorKeys.Magenta]})` }; 56 | styleAttrsMap[36] = { [StyleAttrsKeys.Color]: `var(--ansi-cyan, ${xtermjs[StandardColorKeys.Cyan]})` }; 57 | styleAttrsMap[37] = { [StyleAttrsKeys.Color]: `var(--ansi-white, ${xtermjs[StandardColorKeys.White]})` }; 58 | styleAttrsMap[39] = StyleAttrs.delete(StyleAttrsKeys.Color); 59 | styleAttrsMap[40] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-black, ${xtermjs[StandardColorKeys.Black]})` }; 60 | styleAttrsMap[41] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-red, ${xtermjs[StandardColorKeys.Red]})` }; 61 | styleAttrsMap[42] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-green, ${xtermjs[StandardColorKeys.Green]})` }; 62 | styleAttrsMap[43] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-yellow, ${xtermjs[StandardColorKeys.Yellow]})` }; 63 | styleAttrsMap[44] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-blue, ${xtermjs[StandardColorKeys.Blue]})` }; 64 | styleAttrsMap[45] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-magenta, ${xtermjs[StandardColorKeys.Magenta]})` }; 65 | styleAttrsMap[46] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-cyan, ${xtermjs[StandardColorKeys.Cyan]})` }; 66 | styleAttrsMap[47] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-white, ${xtermjs[StandardColorKeys.White]})` }; 67 | styleAttrsMap[49] = StyleAttrs.delete(StyleAttrsKeys.BackgroundColor); 68 | styleAttrsMap[51] = { [StyleAttrsKeys.Outline]: 'var(--ansi-frame-outline, 1px solid)' }; 69 | styleAttrsMap[53] = StyleAttrs.appendVal(StyleAttrsKeys.TextDecoration, 'overline'); 70 | styleAttrsMap[54] = StyleAttrs.delete(StyleAttrsKeys.Outline); 71 | styleAttrsMap[55] = StyleAttrs.removeVal(StyleAttrsKeys.TextDecoration, 'overline'); 72 | styleAttrsMap[59] = StyleAttrs.delete(StyleAttrsKeys.TextDecorationColor); 73 | styleAttrsMap[73] = { [StyleAttrsKeys.VerticalAlign]: 'super', [StyleAttrsKeys.FontSize]: 'var(--ansi-superscript-font-size, 80%)' }; 74 | styleAttrsMap[74] = { [StyleAttrsKeys.VerticalAlign]: 'sub', [StyleAttrsKeys.FontSize]: 'var(--ansi-subscript-font-size, 80%)' }; 75 | styleAttrsMap[75] = StyleAttrs.delete(StyleAttrsKeys.VerticalAlign, StyleAttrsKeys.FontSize); 76 | styleAttrsMap[90] = { [StyleAttrsKeys.Color]: `var(--ansi-bright-black, ${xtermjs[StandardColorKeys.BrightBlack]})` }; 77 | styleAttrsMap[91] = { [StyleAttrsKeys.Color]: `var(--ansi-bright-red, ${xtermjs[StandardColorKeys.BrightRed]})` }; 78 | styleAttrsMap[92] = { [StyleAttrsKeys.Color]: `var(--ansi-bright-green, ${xtermjs[StandardColorKeys.BrightGreen]})` }; 79 | styleAttrsMap[93] = { [StyleAttrsKeys.Color]: `var(--ansi-bright-yellow, ${xtermjs[StandardColorKeys.BrightYellow]})` }; 80 | styleAttrsMap[94] = { [StyleAttrsKeys.Color]: `var(--ansi-bright-blue, ${xtermjs[StandardColorKeys.BrightBlue]})` }; 81 | styleAttrsMap[95] = { [StyleAttrsKeys.Color]: `var(--ansi-bright-magenta, ${xtermjs[StandardColorKeys.BrightMagenta]})` }; 82 | styleAttrsMap[96] = { [StyleAttrsKeys.Color]: `var(--ansi-bright-cyan, ${xtermjs[StandardColorKeys.BrightCyan]})` }; 83 | styleAttrsMap[97] = { [StyleAttrsKeys.Color]: `var(--ansi-bright-white, ${xtermjs[StandardColorKeys.BrightWhite]})` }; 84 | styleAttrsMap[100] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-bright-black, ${xtermjs[StandardColorKeys.BrightBlack]})` }; 85 | styleAttrsMap[101] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-bright-red, ${xtermjs[StandardColorKeys.BrightRed]})` }; 86 | styleAttrsMap[102] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-bright-green, ${xtermjs[StandardColorKeys.BrightGreen]})` }; 87 | styleAttrsMap[103] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-bright-yellow, ${xtermjs[StandardColorKeys.BrightYellow]})` }; 88 | styleAttrsMap[104] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-bright-blue, ${xtermjs[StandardColorKeys.BrightBlue]})` }; 89 | styleAttrsMap[105] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-bright-magenta, ${xtermjs[StandardColorKeys.BrightMagenta]})` }; 90 | styleAttrsMap[106] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-bright-cyan, ${xtermjs[StandardColorKeys.BrightCyan]})` }; 91 | styleAttrsMap[107] = { [StyleAttrsKeys.BackgroundColor]: `var(--ansi-bright-white, ${xtermjs[StandardColorKeys.BrightWhite]})` }; 92 | 93 | const codeMap = new Array(16); 94 | codeMap[0] = StandardColorKeys.Black; 95 | codeMap[1] = StandardColorKeys.Red; 96 | codeMap[2] = StandardColorKeys.Green; 97 | codeMap[3] = StandardColorKeys.Yellow; 98 | codeMap[4] = StandardColorKeys.Blue; 99 | codeMap[5] = StandardColorKeys.Magenta; 100 | codeMap[6] = StandardColorKeys.Cyan; 101 | codeMap[7] = StandardColorKeys.White; 102 | codeMap[8] = StandardColorKeys.BrightBlack; 103 | codeMap[9] = StandardColorKeys.BrightRed; 104 | codeMap[10] = StandardColorKeys.BrightGreen; 105 | codeMap[11] = StandardColorKeys.BrightYellow; 106 | codeMap[12] = StandardColorKeys.BrightBlue; 107 | codeMap[13] = StandardColorKeys.BrightMagenta; 108 | codeMap[14] = StandardColorKeys.BrightCyan; 109 | codeMap[15] = StandardColorKeys.BrightWhite; 110 | 111 | /** 112 | * Get key from code 113 | * @param {string} code 114 | * @returns {StyleAttrsKey} The key 115 | */ 116 | function getStyleAttrsKey(code: string): StyleAttrsKeys { 117 | switch (code) { 118 | case '38': 119 | return StyleAttrsKeys.Color; 120 | case '48': 121 | return StyleAttrsKeys.BackgroundColor; 122 | case '58': 123 | return StyleAttrsKeys.TextDecorationColor; 124 | default: 125 | throw new Error('not implemented'); 126 | } 127 | } 128 | 129 | /** 130 | * Read SGR packet and apply 256-color palette to style attributes 131 | * @param {Packet} packet - The parsed packet 132 | * @param {StyleAttrs} attrs - The current style attributes 133 | */ 134 | function processSGRPacket256(packet: Packet, attrs: StyleAttrs) { 135 | const parts = packet.text.split(';'); 136 | if (parts.length !== 3) return; 137 | 138 | const key = getStyleAttrsKey(parts[0]); 139 | const colorCode = parseInt(parts[2], 10); 140 | 141 | if (0 <= colorCode && colorCode <= 15) { 142 | const colorKey = codeMap[colorCode]; 143 | attrs.set(key, `var(--ansi-${colorKey}, ${xtermjs[colorKey]})`); 144 | } else if (16 <= colorCode && colorCode <= 231) { 145 | // 6x6 rgb cube 146 | const x = colorCode - 16; 147 | const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]; 148 | const r = v[(x / 36) % 6 | 0]; 149 | const g = v[(x / 6) % 6 | 0]; 150 | const b = v[x % 6]; 151 | attrs.set(key, `rgb(${r},${g},${b})`); 152 | } else if (232 <= colorCode && colorCode <= 255) { 153 | // 24-step grayscale 154 | const c = 8 + (colorCode - 232) * 10; 155 | attrs.set(key, `var(--ansi-gray-${256 - colorCode}, rgb(${c},${c},${c}))`); 156 | } 157 | } 158 | 159 | /** 160 | * Read SGR packet and apply truecolor properties to style attributes 161 | * @param {Packet} packet - The parsed packet 162 | * @param {StyleAttrs} attrs - The current style attributes 163 | */ 164 | function processSGRPacketTruecolor(packet: Packet, attrs: StyleAttrs) { 165 | const parts = packet.text.split(';'); 166 | if (parts.length !== 5) return; 167 | 168 | const key = getStyleAttrsKey(parts[0]); 169 | const [, , r, g, b] = parts; 170 | attrs.set(key, `rgb(${r},${g},${b})`); 171 | } 172 | 173 | /** 174 | * Read SGR packet and modify style attributes accordingly 175 | * @param {Packet} packet - The parsed packet 176 | * @param {StyleAttrs} attrs - The current style attributes 177 | */ 178 | function processSGRPacket(packet: Packet, attrs: StyleAttrs) { 179 | if (/^(38|48|58);(2|5);/.test(packet.text)) { 180 | if (packet.text[3] === '2') processSGRPacketTruecolor(packet, attrs); 181 | else processSGRPacket256(packet, attrs); 182 | return; 183 | } 184 | 185 | packet.text.split(';').forEach((codeStr) => { 186 | const code = parseInt(codeStr || '0', 10); 187 | 188 | // handle reset 189 | if (code === 0) { 190 | attrs.clear(); 191 | return; 192 | } 193 | 194 | // get attribute modifier 195 | const newAttrsOrModifierFn = styleAttrsMap[code]; 196 | if (!newAttrsOrModifierFn) return; 197 | 198 | // update attrs 199 | if (typeof newAttrsOrModifierFn === 'function') newAttrsOrModifierFn(attrs); 200 | else attrs.update(newAttrsOrModifierFn); 201 | }); 202 | } 203 | 204 | /** 205 | * Check if a given string has ANSI markup 206 | * @param {string} input - The input string 207 | * @returns {boolean} Whether or not the input string has ANSI markup 208 | */ 209 | export function hasAnsi(input: string): boolean { 210 | return hasAnsiRegex.test(input); 211 | } 212 | 213 | /** 214 | * Strip out all ANSI markup 215 | * @param {string} input - The input string 216 | * @returns {string} The input string with ANSI escape codes replaced with '' 217 | */ 218 | export function stripAnsi(input: string): string { 219 | return input.replaceAll(ansiRegex, ''); 220 | } 221 | 222 | /** 223 | * Represents a configurable ANSI to HTML converter object 224 | */ 225 | export class FancyAnsi { 226 | /** 227 | * Convert input string with ansi markup to browser-safe HTML 228 | * @param {string} input 229 | * @returns {string} A browser-safe HTML string with converted ANSI 230 | */ 231 | public toHtml(input: string): string { 232 | const attrs: StyleAttrs = new StyleAttrs(); 233 | let buffer = ''; 234 | let inTag = false; 235 | 236 | // iterate through packets 237 | Array.from(new Parser(input)).forEach((packet) => { 238 | switch (packet.kind) { 239 | case PacketKind.Text: 240 | buffer += escape(packet.text); 241 | break; 242 | case PacketKind.SGR: 243 | if (inTag) { 244 | buffer += '
'; 245 | inTag = false; 246 | } 247 | 248 | processSGRPacket(packet, attrs); 249 | 250 | if (attrs.size) { 251 | buffer += ``; 252 | inTag = true; 253 | } 254 | break; 255 | default: 256 | break; 257 | } 258 | }); 259 | 260 | // add closing tag if necessary 261 | if (inTag) buffer += ''; 262 | 263 | return buffer; 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fancy-ANSI 2 | 3 | Fancy-ANSI is a small JavaScript library for converting ANSI to beautiful, browser-safe HTML 4 | 5 | Screen Shot 2024-03-30 at 1 19 54 PM 6 | 7 | Demo: [https://www.kubetail.com/demo](https://www.kubetail.com/demo) 8 | Preview: [https://kubetail-org.github.io/fancy-ansi/](https://kubetail-org.github.io/fancy-ansi/) 9 | 10 | ## Introduction 11 | 12 | While adding ANSI markup support to [kubetail](https://github.com/kubetail-org/kubetail) we tested out several popular popular ANSI-to-html conversion libraries (e.g. [ansi-html-community](https://github.com/mahdyar/ansi-html-community), [ansi-to-html](https://github.com/rburns/ansi-to-html), [ansi_up](https://github.com/drudru/ansi_up)) and ran into a few problems: 13 | 14 | * Failure to parse some of our users' ANSI markup 15 | * Use of hard-coded styles that made customization more difficult 16 | * Lack of support for CSS variables 17 | 18 | To solve these problems and make something that integrated nicely into our frontend stack (Tailwind, React) we created Fancy-ANSI. The library is designed to be small (~4 kb gzipped), performant, easy-to-use and safe from XSS attacks. It has the following features: 19 | 20 | * Supports easy customization using CSS variables 21 | * Supports almost all SGR codes 22 | * Includes a Tailwind plugin that enables support for easy theming 23 | * Includes a React component for easy use in a React environment 24 | * Includes useful utilities like `hasAnsi()` and `stripAnsi()` that come in handy when working with ANSI 25 | * Includes popular color palettes that can be swapped in easily 26 | 27 | Try it out and let us know what you think! If you notice any bugs or have any feature requests just create a GitHub Issue. 28 | 29 | ## Quickstart 30 | 31 | Install the library using your favorite package manager: 32 | 33 | ```sh 34 | # npm 35 | npm install fancy-ansi 36 | 37 | # yarn 38 | yarn add fancy-ansi 39 | 40 | # pnpm 41 | pnpm add fancy-ansi 42 | ``` 43 | 44 | Now you can use it in your code with React: 45 | 46 | ```jsx 47 | // ExampleComponent.jsx 48 | import { AnsiHtml } from 'fancy-ansi/react'; 49 | 50 | export const ExampleComponent = () => { 51 | const text = '\x1b[34mhello \x1b[33mworld\x1b[0m'; 52 | return ; 53 | }; 54 | ``` 55 | 56 | Or with Vanilla-JS: 57 | 58 | ```typescript 59 | // example.ts 60 | import { FancyAnsi } from 'fancy-ansi'; 61 | 62 | const fancyAnsi = new FancyAnsi(); 63 | 64 | export function addElementWithAnsi() { 65 | const el = document.createElement('div'); 66 | el.innerHTML = fancyAnsi.toHtml('\x1b[34mhello \x1b[33mworld\x1b[0m'); 67 | document.body.append(el); 68 | } 69 | ``` 70 | 71 | The HTML rendered is browser-safe and ready with some sensible color choices that can be customized easily (see below). 72 | 73 | ## Configuration 74 | 75 | You can configure Fancy-ANSI using CSS variables. For example, to invert blacks in dark mode you can change the value of black when the document has a "dark" class: 76 | 77 | ```css 78 | :root { 79 | --ansi-black: #000; 80 | } 81 | 82 | .dark { 83 | --ansi-black: #FFF; 84 | } 85 | ``` 86 | 87 | The full list of supported variables can be found in the [SGR Parameters](#sgr-parameters) section below. 88 | 89 | ## SGR Parameters 90 | 91 | | n | Name | Supported | CSS Variables | Default | 92 | | --- | ---------------------------------------- | ------------------ | ---------------------------- | --------- | 93 | | 0 | Reset | :heavy_check_mark: | | | 94 | | 1 | Bold | :heavy_check_mark: | --ansi-bold-font-weight | 600 | 95 | | 2 | Dim | :heavy_check_mark: | --ansi-dim-opacity | 0.7 | 96 | | 3 | Italic | :heavy_check_mark: | | | 97 | | 4 | Underline | :heavy_check_mark: | | | 98 | | 5 | Slow blink | | | | 99 | | 6 | Fast blink | | | | 100 | | 7 | Invert | | | | 101 | | 8 | Hide | :heavy_check_mark: | | | 102 | | 9 | Strikethrough | :heavy_check_mark: | | | 103 | | 10 | Default font | :heavy_check_mark: | | | 104 | | 11 | Alternative font 1 | :heavy_check_mark: | --ansi-font-1 | | 105 | | 12 | Alternative font 2 | :heavy_check_mark: | --ansi-font-2 | | 106 | | 13 | Alternative font 3 | :heavy_check_mark: | --ansi-font-3 | | 107 | | 14 | Alternative font 4 | :heavy_check_mark: | --ansi-font-4 | | 108 | | 15 | Alternative font 5 | :heavy_check_mark: | --ansi-font-5 | | 109 | | 16 | Alternative font 6 | :heavy_check_mark: | --ansi-font-6 | | 110 | | 17 | Alternative font 7 | :heavy_check_mark: | --ansi-font-7 | | 111 | | 18 | Alternative font 8 | :heavy_check_mark: | --ansi-font-8 | | 112 | | 19 | Alternative font 9 | :heavy_check_mark: | --ansi-font-9 | | 113 | | 20 | Gothic | | | | 114 | | 21 | Double underline | :heavy_check_mark: | | | 115 | | 22 | Bold off | :heavy_check_mark: | | | 116 | | 23 | Italic off | :heavy_check_mark: | | | 117 | | 24 | Underline off | :heavy_check_mark: | | | 118 | | 25 | Blink off | | | | 119 | | 26 | Proportional spacing | | | | 120 | | 27 | Invert off | | | | 121 | | 28 | Hidden off | :heavy_check_mark: | | | 122 | | 29 | Strikethrough off | :heavy_check_mark: | | | 123 | | 30 | Foreground color - black | :heavy_check_mark: | --ansi-black | #2e3436 | 124 | | 31 | Foreground color - red | :heavy_check_mark: | --ansi-red | #cc0000 | 125 | | 32 | Foreground color - green | :heavy_check_mark: | --ansi-green | #4e9a06 | 126 | | 33 | Foreground color - yellow | :heavy_check_mark: | --ansi-yellow | #c4a000 | 127 | | 34 | Foreground color - blue | :heavy_check_mark: | --ansi-blue | #3465a4 | 128 | | 35 | Foregorund color - magenta | :heavy_check_mark: | --ansi-magenta | #75507b | 129 | | 36 | Foreground color - cyan | :heavy_check_mark: | --ansi-cyan | #06989a | 130 | | 37 | Foreground color - white | :heavy_check_mark: | --ansi-white | #d3d7cf | 131 | | 38 | Foreground color - extended (see below) | :heavy_check_mark: | | | 132 | | 39 | Default foreground color | :heavy_check_mark: | | | 133 | | 40 | Background color - black | :heavy_check_mark: | --ansi-black | #2e3436 | 134 | | 41 | Background color - red | :heavy_check_mark: | --ansi-red | #cc0000 | 135 | | 42 | Background color - green | :heavy_check_mark: | --ansi-green | #4e9a06 | 136 | | 43 | Background color - yellow | :heavy_check_mark: | --ansi-yellow | #c4a000 | 137 | | 44 | Background color - blue | :heavy_check_mark: | --ansi-blue | #3465a4 | 138 | | 45 | Background color - magenta | :heavy_check_mark: | --ansi-magenta | #75507b | 139 | | 46 | Background color - cyan | :heavy_check_mark: | --ansi-cyan | #06989a | 140 | | 47 | Background color - white | :heavy_check_mark: | --ansi-white | #d3d7cf | 141 | | 48 | Background color - extended (see below) | :heavy_check_mark: | | | 142 | | 49 | Default background color | :heavy_check_mark: | | | 143 | | 50 | Proportional spacing off | | | | 144 | | 51 | Frame | :heavy_check_mark: | --ansi-frame-outline | 1px solid | 145 | | 52 | Encircle | | | | 146 | | 53 | Overline | :heavy_check_mark: | | | 147 | | 54 | Frame/encircle off | :heavy_check_mark: | | | 148 | | 55 | Overline off | :heavy_check_mark: | | | 149 | | 58 | Underground color - extended (see below) | :heavy_check_mark: | | | 150 | | 59 | Default underline color | :heavy_check_mark: | | | 151 | | 60 | Right side line | | | | 152 | | 61 | Double line on the right side | | | | 153 | | 62 | Left side line | | | | 154 | | 63 | Double line on the left side | | | | 155 | | 64 | Ideogram stress marking | | | | 156 | | 65 | Side lines off | | | | 157 | | 73 | Superscript | :heavy_check_mark: | --ansi-superscript-font-size | 80% | 158 | | 74 | Subscript | :heavy_check_mark: | --ansi-subscript-font-size | 80% | 159 | | 75 | Superscript/subscript off | :heavy_check_mark: | | | 160 | | 90 | Foreground color - bright black | :heavy_check_mark: | --ansi-bright-black | #555753 | 161 | | 91 | Foreground color - bright red | :heavy_check_mark: | --ansi-bright-red | #ef2929 | 162 | | 92 | Foreground color - bright green | :heavy_check_mark: | --ansi-bright-green | #8ae234 | 163 | | 93 | Foreground color - bright yellow | :heavy_check_mark: | --ansi-bright-yellow | #fce94f | 164 | | 94 | Foreground color - bright blue | :heavy_check_mark: | --ansi-bright-blue | #729fcf | 165 | | 95 | Foreground color - bright magenta | :heavy_check_mark: | --ansi-bright-magenta | #ad7fa8 | 166 | | 96 | Foreground color - bright cyan | :heavy_check_mark: | --ansi-bright-cyan | #34e2e2 | 167 | | 97 | Foreground color - bright white | :heavy_check_mark: | --ansi-bright-white | #eeeeec | 168 | | 100 | Background color - bright black | :heavy_check_mark: | --ansi-bright-black | #555753 | 169 | | 101 | Background color - bright red | :heavy_check_mark: | --ansi-bright-red | #ef2929 | 170 | | 102 | Background color - bright green | :heavy_check_mark: | --ansi-bright-green | #8ae234 | 171 | | 103 | Background color - bright yellow | :heavy_check_mark: | --ansi-bright-yellow | #fce94f | 172 | | 104 | Background color - bright blue | :heavy_check_mark: | --ansi-bright-blue | #729fcf | 173 | | 105 | Background color - bright magenta | :heavy_check_mark: | --ansi-bright-magenta | #ad7fa8 | 174 | | 106 | Background color - bright cyan | :heavy_check_mark: | --ansi-bright-cyan | #34e2e2 | 175 | | 107 | Background color - bright white | :heavy_check_mark: | --ansi-bright-white | #eeeeec | 176 | 177 | Extended colors: 178 | 179 | | Code pattern | Description | CSS Variables | 180 | | ------------------------ | ---------------------------------------- | ------------------ | 181 | | 38;2;{r};{g};{b} | Set foreground color - (r,g,b) | | 182 | | 38;5;{n} (0 ≤ n ≤ 15) | Set foreground color - standard colors | --ansi-{color} | 183 | | 38;5;{n} (16 ≤ n ≤ 231) | Set foreground color - 6x6 rgb cube | | 184 | | 38;5;{n} (232 ≤ n ≤ 232) | Set foreground color - 24-step grayscale | --ansi-gray-{step} | 185 | | 48;2;{r};{g};{b} | Set background color - (r,g,b) | | 186 | | 48;5;{n} (0 ≤ n ≤ 15) | Set background color - standard colors | --ansi-{color} | 187 | | 48;5;{n} (16 ≤ n ≤ 231) | Set background color - 6x6 rgb cube | | 188 | | 48;5;{n} (232 ≤ n ≤ 232) | Set background color - 24-step grayscale | --ansi-gray-{step} | 189 | | 58;2;{r};{g};{b} | Set underline color - (r,g,b) | | 190 | | 58;5;{n} (0 ≤ n ≤ 15) | Set underline color - standard colors | --ansi-{color} | 191 | | 58;5;{n} (16 ≤ n ≤ 231) | Set underline color - 6x6 rgb cube | | 192 | | 58;5;{n} (232 ≤ n ≤ 232) | Set underline color - 24-step grayscale | --ansi-gray-{step} | 193 | 194 | ## Integrations 195 | 196 | ### Tailwind 197 | 198 | The Fancy-ANSI Tailwind plugin makes it easy to support theming and to access multiple built-in palettes from your css. To use the plugin, add it to your `tailwind.config.js` file: 199 | 200 | ```js 201 | // tailwind.config.js 202 | module.exports = { 203 | plugins: [ 204 | // ... 205 | require('fancy-ansi/plugin') 206 | ] 207 | } 208 | ``` 209 | 210 | Now you can access the built-in palettes using the Tailwind `theme()` function. For example, you can implement two different palettes for light/dark mode like this: 211 | 212 | ```css 213 | /* index.css */ 214 | @tailwind base; 215 | @tailwind components; 216 | @tailwind utilities; 217 | 218 | @layer base { 219 | :root { 220 | --ansi-black: theme(ansi.colors.vscode.black); 221 | --ansi-red: theme(ansi.colors.vscode.red); 222 | --ansi-green: theme(ansi.colors.vscode.green); 223 | --ansi-yellow: theme(ansi.colors.vscode.yellow); 224 | --ansi-blue: theme(ansi.colors.vscode.blue); 225 | --ansi-magenta: theme(ansi.colors.vscode.magenta); 226 | --ansi-cyan: theme(ansi.colors.vscode.cyan); 227 | --ansi-white: theme(ansi.colors.vscode.white); 228 | --ansi-bright-black: theme(ansi.colors.vscode.bright-black); 229 | --ansi-bright-red: theme(ansi.colors.vscode.bright-red); 230 | --ansi-bright-green: theme(ansi.colors.vscode.bright-green); 231 | --ansi-bright-yellow: theme(ansi.colors.vscode.bright-yellow); 232 | --ansi-bright-blue: theme(ansi.colors.vscode.bright-blue); 233 | --ansi-bright-magenta: theme(ansi.colors.vscode.magenta); 234 | --ansi-bright-cyan: theme(ansi.colors.vscode.cyan); 235 | --ansi-bright-white: theme(ansi.colors.vscode.white); 236 | } 237 | 238 | .dark { 239 | --ansi-black: theme(ansi.colors.xtermjs.black); 240 | --ansi-red: theme(ansi.colors.xtermjs.red); 241 | --ansi-green: theme(ansi.colors.xtermjs.green); 242 | --ansi-yellow: theme(ansi.colors.xtermjs.yellow); 243 | --ansi-blue: theme(ansi.colors.xtermjs.blue); 244 | --ansi-magenta: theme(ansi.colors.xtermjs.magenta); 245 | --ansi-cyan: theme(ansi.colors.xtermjs.cyan); 246 | --ansi-white: theme(ansi.colors.xtermjs.white); 247 | --ansi-bright-black: theme(ansi.colors.xtermjs.bright-black); 248 | --ansi-bright-red: theme(ansi.colors.xtermjs.bright-red); 249 | --ansi-bright-green: theme(ansi.colors.xtermjs.bright-green); 250 | --ansi-bright-yellow: theme(ansi.colors.xtermjs.bright-yellow); 251 | --ansi-bright-blue: theme(ansi.colors.xtermjs.bright-blue); 252 | --ansi-bright-magenta: theme(ansi.colors.xtermjs.magenta); 253 | --ansi-bright-cyan: theme(ansi.colors.xtermjs.cyan); 254 | --ansi-bright-white: theme(ansi.colors.xtermjs.white); 255 | } 256 | ``` 257 | 258 | ### React 259 | 260 | Fancy-ANSI has a convenient React component that you can import from the `fancy-ansi/react` module: 261 | 262 | ```jsx 263 | // ExampleComponent.jsx 264 | import { AnsiHtml } from 'fancy-ansi/react'; 265 | 266 | export const ExampleComponent = () => { 267 | const text = '\x1b[34mhello \x1b[33mworld\x1b[0m'; 268 | return ; 269 | }; 270 | ``` 271 | 272 | ## Examples 273 | 274 | You can see some example implementations in the [`examples/`](examples/) directory: 275 | 276 | * [Vite - React](examples/vite-react) 277 | * [Next.js](examples/nextjs) 278 | 279 | ## API 280 | 281 | ### FancyAnsi - The converter class 282 | 283 | ``` 284 | FancyAnsi 285 | 286 | toHtml(input) 287 | * @param {string} input - The input string 288 | * @returns {string} Browser-safe HTML string containing stylized ANSI content 289 | 290 | Example: 291 | 292 | import { FancyAnsi } from 'fancy-ansi'; 293 | 294 | const fancyAnsi = new FancyAnsi(); 295 | fancyAnsi.toHtml('\x1b[1mThis is in bold.\x1b[0m'); 296 | ``` 297 | 298 | ### hasAnsi() - Check if a string has ANSI markup 299 | 300 | ``` 301 | hasAnsi(input) 302 | 303 | * @param {string} input - The input string 304 | * @returns {boolean} Boolean indicating whether or not input string contains ANSI markup 305 | 306 | Example: 307 | 308 | import { hasAnsi } from 'fancy-ansi'; 309 | 310 | if (hasAnsi('\x1b[1mThis is in bold.\x1b[0m')) { 311 | console.log('string has ansi'); 312 | } else { 313 | console.log('string doesn\'t have ansi'); 314 | } 315 | ``` 316 | 317 | ### stripAnsi() - Remove ANSI markup 318 | 319 | ``` 320 | stripAnsi(input) 321 | 322 | * @param {string} input - The input string 323 | * @returns {string} Content of input string with ANSI markup removed 324 | 325 | Example: 326 | 327 | import { stripAnsi } from 'fancy-ansi'; 328 | 329 | const withoutAnsi = stripAnsi('\x1b[1mThis is in bold.\x1b[0m'); 330 | console.log(`string without ansi: ${withoutAnsi}`); 331 | ``` 332 | 333 | ### colors - Built-in palettes 334 | 335 | ``` 336 | `fancy-ansi/colors` module 337 | 338 | Example: 339 | 340 | import { xtermjs, terminalapp } from 'fancy-ansi/colors'; 341 | 342 | console.log(`xterm.js red: ${xtermjs.red}`); 343 | console.log(`Terminal.app red: ${terminalapp.red}`); 344 | 345 | Available palettes: 346 | 347 | * eclipse 348 | * putty 349 | * terminalapp 350 | * ubuntu 351 | * vga 352 | * vscode 353 | * windows10 354 | * xterm 355 | * xtermjs 356 | ``` 357 | 358 | ## Development 359 | 360 | ### Get the code 361 | 362 | To develop Fancy-ANSI, first clone the repository then install the dependencies: 363 | 364 | ```sh 365 | git clone git@github.com:kubetail-org/fancy-ansi.git 366 | cd fancy-ansi 367 | pnpm install 368 | ``` 369 | 370 | ### Run the dev server 371 | 372 | Fancy-ANSI uses vite for development. To run run the vite dev server, use the `dev` command: 373 | 374 | ```sh 375 | pnpm dev 376 | ``` 377 | 378 | Now you can access the demo page and see your changes at [http://localhost:5173/](http://localhost:5173/). 379 | 380 | ### Run the unit tests 381 | 382 | Fancy-ANSI uses jest for testing (via vitest). To run the tests, use the `test` command: 383 | 384 | ```sh 385 | pnpm test 386 | ``` 387 | 388 | The test files are colocated with the source code in the `src/` directory, with the filename format `{name}.test.(ts|tsx)`. 389 | 390 | ### Build for production 391 | 392 | To build Fancy-ANSI for production, run the `build` command: 393 | 394 | ```sh 395 | pnpm build 396 | ``` 397 | 398 | The production files will be located in the `dist/` directory. 399 | 400 | ## Acknowledgements 401 | 402 | * The ANSI parsing code is from [ansi_up](https://github.com/drudru/ansi_up) 403 | * has/strip methods use [Chalk's](https://github.com/chalk/chalk) [ansi-regex](https://github.com/chalk/ansi-regex) 404 | * [Fancy Nancy](https://www.fancynancyworld.com) 405 | --------------------------------------------------------------------------------