├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── demo ├── css │ ├── demo.css │ └── prism.css ├── demo.tsx └── index.html ├── package-config.ts ├── package-lock.json ├── package.json ├── src ├── Refractor.tsx ├── addMarkers.ts ├── index.ts ├── mapChildren.ts └── types.ts ├── test ├── .eslintrc ├── Refractor.test.ts ├── __snapshots__ │ ├── Refractor.test.ts.snap │ └── addMarkers.test.ts.snap ├── addMarkers.test.ts └── fixtures │ └── ast.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | # Use hard or soft tabs 6 | indent_style = space 7 | 8 | # Size of a single indent 9 | indent_size = tab 10 | 11 | # Number of columns representing a tab character 12 | tab_width = 2 13 | 14 | # Use line-feed as EOL indicator 15 | end_of_line = lf 16 | 17 | # Use UTF-8 character encoding for all files 18 | charset = utf-8 19 | 20 | # Remove any whitespace characters preceding newline characters 21 | trim_trailing_whitespace = true 22 | 23 | # Ensure file ends with a newline when saving 24 | insert_final_newline = true 25 | 26 | [*.md] 27 | trim_trailing_whitespace = false 28 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | demo/dist/* 2 | coverage/* 3 | dist/* 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["sanity", "sanity/typescript", "prettier"], 3 | "parserOptions": { 4 | "ecmaFeatures": { 5 | "modules": true, 6 | }, 7 | "ecmaVersion": 9, 8 | "sourceType": "module", 9 | }, 10 | "ignorePatterns": ["lib/**/"], 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | inputs: 8 | release: 9 | description: 'Publish new release' 10 | required: true 11 | default: false 12 | type: boolean 13 | jobs: 14 | test: 15 | name: Test node.js ${{ matrix.node-version }} 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | node-version: 21 | - 18 22 | - 20 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: npm 29 | - run: npm install 30 | - run: npm run build 31 | - run: npm test 32 | demo: 33 | name: Deploy demo 34 | needs: [test] 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - uses: actions/setup-node@v3 39 | with: 40 | node-version: 20 41 | cache: npm 42 | - run: npm install 43 | - run: npm run build:demo 44 | - uses: peaceiris/actions-gh-pages@v3 45 | with: 46 | github_token: ${{ secrets.GITHUB_TOKEN }} 47 | publish_dir: ./demo/dist 48 | release: 49 | name: Release 50 | needs: [test] 51 | # only run if opt-in during workflow_dispatch 52 | if: always() && github.event.inputs.release == 'true' 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v3 56 | with: 57 | # Need to fetch entire commit history to 58 | # analyze every commit since last release 59 | fetch-depth: 0 60 | - uses: actions/setup-node@v3 61 | with: 62 | node-version: lts/* 63 | cache: npm 64 | - run: npm ci 65 | # Branches that will release new versions are defined in .releaserc.json 66 | - run: npx semantic-release 67 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 68 | # e.g. git tags were pushed but it exited before `npm publish` 69 | if: always() 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # Demo build assets 36 | demo/js/demo.min.js.map 37 | 38 | # Compiled output 39 | demo/dist/* 40 | dist/* 41 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [3.1.1](https://github.com/rexxars/react-refractor/compare/v3.1.0...v3.1.1) (2024-04-09) 9 | 10 | ### Bug Fixes 11 | 12 | - add `main` entrypoint for better ecosystem compatibility ([2a4e230](https://github.com/rexxars/react-refractor/commit/2a4e230d0996aacf540273aa59795580729e0f9f)) 13 | 14 | ## [3.1.0](https://github.com/rexxars/react-refractor/compare/v3.0.0...v3.1.0) (2024-04-08) 15 | 16 | ### Features 17 | 18 | - add `plainText` option/prop ([2148090](https://github.com/rexxars/react-refractor/commit/2148090e3c8ee8edf92f4bda9556224f99c2406d)), closes [#23](https://github.com/rexxars/react-refractor/issues/23) 19 | 20 | ## [3.0.0](https://github.com/rexxars/react-refractor/compare/v2.1.7...v3.0.0) (2024-04-08) 21 | 22 | ### ⚠ BREAKING CHANGES 23 | 24 | - Module is now ESM-only, and will not work in CommonJS environments. 25 | - Module uses named exports, eg `import {Refractor} from 'react-refractor'` 26 | - Module now requires React 18 or higher. 27 | - Drop `all` export (eg `react-refractor/all`) 28 | - Drop UMD bundle 29 | - Drop ES5 compatibility. Now requires an ES6-compatible environment. 30 | 31 | ### Features 32 | 33 | - use refractor 4, ESM-only ([395a739](https://github.com/rexxars/react-refractor/commit/395a7394c7be26e423d0ccbcfefac4955864650b)) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Espen Hovlandsdal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-refractor 2 | 3 | Syntax highlighter for React, utilizing VDOM for efficient updates 4 | 5 | [![npm version](http://img.shields.io/npm/v/react-refractor.svg?style=flat-square)](http://browsenpm.org/package/react-refractor) 6 | 7 | - Thin wrapper on top of [refractor](https://github.com/wooorm/refractor) (Syntax highlighting using VDOM) 8 | - refractor uses [Prism](https://github.com/PrismJS/prism) under the hood, thus supports all the same syntaxes 9 | - About 14kB minified + gziped when using a single language syntax. Languages tend to add a bit of weight, see [unpkg](https://unpkg.com/refractor/lang/) for some pointers on how much. 10 | 11 | Feel free to check out a [super-simple demo](http://rexxars.github.io/react-refractor/). 12 | 13 | ## Installation 14 | 15 | This package is [ESM only][esm] and requires React 18 or higher. 16 | 17 | ``` 18 | npm install --save react-refractor 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```js 24 | import {Refractor, registerLanguage} from 'react-refractor' 25 | 26 | // Load any languages you want to use from `refractor` 27 | import js from 'refractor/lang/javascript.js' 28 | import php from 'refractor/lang/php.js' 29 | 30 | // Then register them 31 | registerLanguage(js) 32 | registerLanguage(php) 33 | 34 | ReactDOM.render( 35 | , 36 | document.getElementById('target'), 37 | ) 38 | ``` 39 | 40 | You'll need to register the languages you want to use - I've intentionally left all languages out of the default bundle in order to reduce the bundle size out of the box. Load and register them from [refractor](https://unpkg.com/refractor/lang/) using something like this: 41 | 42 | ```ts 43 | import docker from 'refractor/lang/docker' 44 | 45 | registerLanguage(docker) 46 | ``` 47 | 48 | ## Styling 49 | 50 | Stylesheets are **not** automatically handled for you - but there is [a bunch of premade themes](https://github.com/PrismJS/prism/tree/gh-pages/themes) for Prism which you can simply drop in and they'll "just work". You can either grab these from the source, of pull them in using a CSS loader - whatever works best for you. You can also download a customized stylesheet from Prism's [download customizer](http://prismjs.com/download.html). 51 | 52 | Note that when using the `markers` feature, there is an additional class name called `refractor-marker` which is not defined by Prism, as it's not a part of its feature set. You can either set it yourself, or you can explicitly set class names on markers. 53 | 54 | ## Props 55 | 56 | | Name | Description | 57 | | :---------- | :------------------------------------------------------------------------------------ | 58 | | `className` | Class name for the outermost `pre` tag. Default: `refractor` | 59 | | `language` | Language to use for syntax highlighting this value. Must be registered prior to usage | 60 | | `value` | The code snippet to syntax highlight | 61 | | `inline` | Whether code should be displayed inline (no `
` tag, sets `display: inline`)      |
 62 | | `markers`   | Array of lines to mark. See section on markers below                                  |
 63 | | `plainText` | Set to `true` to skip highlighting and render the passed value as-is                  |
 64 | 
 65 | ## Differences to Prism
 66 | 
 67 | Prism.js operates directly on the DOM, while refractor generates an AST which react-refractor walks over and converts into virtual DOM nodes. The benefit of the AST approach is that we can easily reuse this across different platforms, highlight on both the server and the client using the same code base and benefit from Reacts virtual DOM diff algorithm to only update the nodes that change.
 68 | 
 69 | The drawback to this approach is that you cannot use Prism plugins, since they _also_ work and depend directly on the DOM.
 70 | 
 71 | ## Markers
 72 | 
 73 | It's quite common to want to highlight lines when doing syntax highlighting, but Prism uses a very DOM-centric approach to achieve this. In order to make up for this, react-refractor provides a custom plugin that lets you define "markers". Since this is a non-standard feature, you will have to provide your own styling for the `refractor-marker` class name. To highlight lines, simply provide the line numbers in the `markers` property:
 74 | 
 75 | ```js
 76 | const source = `
 77 | const foo = 'bar'
 78 | const bar = 'foo'
 79 | const baz = foo + bar
 80 | `
 81 | 
 82 | // Highlight line 1 and 2, but not 3
 83 | 
 88 | ```
 89 | 
 90 | You are also able to provide greater customization by specifying an object for each marker, which can include either a `className` or a `component` property. This allows you to render basically anything you want:
 91 | 
 92 | ```js
 93 | const source = `
 94 | const foo = 'bar'
 95 | const bar = 'foo'
 96 | const baz = "bar" + bar
 97 | `
 98 | 
 99 | // Highlight line 1 and 2, but not 3
100 |  (
106 |       
107 |         {props.children}
108 |       
109 |     )}
110 |   ]}
111 | />
112 | ```
113 | 
114 | ## License
115 | 
116 | MIT-licensed. See LICENSE.
117 | 
118 | [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
119 | 


--------------------------------------------------------------------------------
/demo/css/demo.css:
--------------------------------------------------------------------------------
 1 | * {
 2 |   box-sizing: border-box;
 3 | }
 4 | 
 5 | html,
 6 | body,
 7 | #root {
 8 |   width: 100%;
 9 |   height: 100%;
10 |   margin: 0;
11 |   padding: 0;
12 | }
13 | 
14 | #root {
15 |   padding: 10px;
16 | }
17 | 
18 | .container {
19 |   display: flex;
20 |   height: 100%;
21 | }
22 | 
23 | h2 {
24 |   margin: 0;
25 |   margin-bottom: 10px;
26 |   font-family: sans-serif;
27 | }
28 | 
29 | pre {
30 |   margin: 0;
31 | }
32 | 
33 | select {
34 |   font-size: 16px;
35 |   margin-left: 1em;
36 | }
37 | 
38 | .input,
39 | .output {
40 |   display: flex;
41 |   flex-direction: column;
42 |   flex: 1;
43 |   padding: 5px;
44 | }
45 | 
46 | .input textarea {
47 |   font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
48 |   font-size: 16px;
49 |   line-height: 1.5;
50 | }
51 | 
52 | .input textarea,
53 | .output .out {
54 |   border: 1px solid #000;
55 |   width: 100%;
56 |   flex: 1;
57 | }
58 | 
59 | .output .out {
60 |   background: #f5f2f0;
61 | }
62 | 
63 | .refractor-marker {
64 |   background: hsla(24, 20%, 50%, 0.08);
65 |   background: linear-gradient(to right, hsla(24, 20%, 50%, 0.1) 70%, hsla(24, 20%, 50%, 0));
66 | }
67 | 
68 | .bitwise {
69 |   background: hsla(51, 82%, 58%, 0.5);
70 |   background: linear-gradient(to right, hsla(51, 83%, 58%, 0.5) 70%, hsla(51, 83%, 58%, 0));
71 |   position: relative;
72 | }
73 | 
74 | .bitwise:hover:before {
75 |   content: 'Eyyh, bitwise!';
76 |   position: absolute;
77 |   right: 0;
78 | }
79 | 


--------------------------------------------------------------------------------
/demo/css/prism.css:
--------------------------------------------------------------------------------
  1 | /* PrismJS 1.14.0
  2 | http://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+jsx&plugins=line-highlight */
  3 | /**
  4 |  * prism.js default theme for JavaScript, CSS and HTML
  5 |  * Based on dabblet (http://dabblet.com)
  6 |  * @author Lea Verou
  7 |  */
  8 | 
  9 | code[class*='language-'],
 10 | pre[class*='language-'] {
 11 |   color: black;
 12 |   background: none;
 13 |   text-shadow: 0 1px white;
 14 |   font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
 15 |   text-align: left;
 16 |   white-space: pre;
 17 |   word-spacing: normal;
 18 |   word-break: normal;
 19 |   word-wrap: normal;
 20 |   line-height: 1.5;
 21 | 
 22 |   -moz-tab-size: 4;
 23 |   -o-tab-size: 4;
 24 |   tab-size: 4;
 25 | 
 26 |   -webkit-hyphens: none;
 27 |   -moz-hyphens: none;
 28 |   -ms-hyphens: none;
 29 |   hyphens: none;
 30 | }
 31 | 
 32 | pre[class*='language-']::-moz-selection,
 33 | pre[class*='language-'] ::-moz-selection,
 34 | code[class*='language-']::-moz-selection,
 35 | code[class*='language-'] ::-moz-selection {
 36 |   text-shadow: none;
 37 |   background: #b3d4fc;
 38 | }
 39 | 
 40 | pre[class*='language-']::selection,
 41 | pre[class*='language-'] ::selection,
 42 | code[class*='language-']::selection,
 43 | code[class*='language-'] ::selection {
 44 |   text-shadow: none;
 45 |   background: #b3d4fc;
 46 | }
 47 | 
 48 | @media print {
 49 |   code[class*='language-'],
 50 |   pre[class*='language-'] {
 51 |     text-shadow: none;
 52 |   }
 53 | }
 54 | 
 55 | /* Code blocks */
 56 | pre[class*='language-'] {
 57 |   padding: 1em;
 58 |   margin: 0.5em 0;
 59 |   overflow: auto;
 60 | }
 61 | 
 62 | :not(pre) > code[class*='language-'],
 63 | pre[class*='language-'] {
 64 |   background: #f5f2f0;
 65 | }
 66 | 
 67 | /* Inline code */
 68 | :not(pre) > code[class*='language-'] {
 69 |   padding: 0.1em;
 70 |   border-radius: 0.3em;
 71 |   white-space: normal;
 72 | }
 73 | 
 74 | .token.comment,
 75 | .token.prolog,
 76 | .token.doctype,
 77 | .token.cdata {
 78 |   color: slategray;
 79 | }
 80 | 
 81 | .token.punctuation {
 82 |   color: #999;
 83 | }
 84 | 
 85 | .namespace {
 86 |   opacity: 0.7;
 87 | }
 88 | 
 89 | .token.property,
 90 | .token.tag,
 91 | .token.boolean,
 92 | .token.number,
 93 | .token.constant,
 94 | .token.symbol,
 95 | .token.deleted {
 96 |   color: #905;
 97 | }
 98 | 
 99 | .token.selector,
100 | .token.attr-name,
101 | .token.string,
102 | .token.char,
103 | .token.builtin,
104 | .token.inserted {
105 |   color: #690;
106 | }
107 | 
108 | .token.operator,
109 | .token.entity,
110 | .token.url,
111 | .language-css .token.string,
112 | .style .token.string {
113 |   color: #9a6e3a;
114 |   background: hsla(0, 0%, 100%, 0.5);
115 | }
116 | 
117 | .token.atrule,
118 | .token.attr-value,
119 | .token.keyword {
120 |   color: #07a;
121 | }
122 | 
123 | .token.function,
124 | .token.class-name {
125 |   color: #dd4a68;
126 | }
127 | 
128 | .token.regex,
129 | .token.important,
130 | .token.variable {
131 |   color: #e90;
132 | }
133 | 
134 | .token.important,
135 | .token.bold {
136 |   font-weight: bold;
137 | }
138 | .token.italic {
139 |   font-style: italic;
140 | }
141 | 
142 | .token.entity {
143 |   cursor: help;
144 | }
145 | 
146 | pre[data-line] {
147 |   position: relative;
148 |   padding: 1em 0 1em 3em;
149 | }
150 | 
151 | .line-highlight {
152 |   position: absolute;
153 |   left: 0;
154 |   right: 0;
155 |   padding: inherit 0;
156 |   margin-top: 1em; /* Same as .prism’s padding-top */
157 | 
158 |   background: hsla(24, 20%, 50%, 0.08);
159 |   background: linear-gradient(to right, hsla(24, 20%, 50%, 0.1) 70%, hsla(24, 20%, 50%, 0));
160 | 
161 |   pointer-events: none;
162 | 
163 |   line-height: inherit;
164 |   white-space: pre;
165 | }
166 | 
167 | .line-highlight:before,
168 | .line-highlight[data-end]:after {
169 |   content: attr(data-start);
170 |   position: absolute;
171 |   top: 0.4em;
172 |   left: 0.6em;
173 |   min-width: 1em;
174 |   padding: 0 0.5em;
175 |   background-color: hsla(24, 20%, 50%, 0.4);
176 |   color: hsl(24, 20%, 95%);
177 |   font: bold 65%/1.5 sans-serif;
178 |   text-align: center;
179 |   vertical-align: 0.3em;
180 |   border-radius: 999px;
181 |   text-shadow: none;
182 |   box-shadow: 0 1px white;
183 | }
184 | 
185 | .line-highlight[data-end]:after {
186 |   content: attr(data-end);
187 |   top: auto;
188 |   bottom: 0.4em;
189 | }
190 | 
191 | .line-numbers .line-highlight:before,
192 | .line-numbers .line-highlight:after {
193 |   content: none;
194 | }
195 | 


--------------------------------------------------------------------------------
/demo/demo.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react'
 2 | import ReactDOM from 'react-dom/client'
 3 | 
 4 | import javascript from 'refractor/lang/javascript.js'
 5 | import markup from 'refractor/lang/markup.js'
 6 | import css from 'refractor/lang/css.js'
 7 | import clike from 'refractor/lang/clike.js'
 8 | import jsx from 'refractor/lang/jsx.js'
 9 | 
10 | import {Refractor, registerLanguage} from '../src/index.js'
11 | 
12 | registerLanguage(javascript)
13 | registerLanguage(markup)
14 | registerLanguage(css)
15 | registerLanguage(clike)
16 | registerLanguage(jsx)
17 | 
18 | const languages = ['jsx', 'javascript', 'markup', 'css', 'clike']
19 | const defaultValue = getDefaultValue()
20 | const BitwiseMarker = (props) => (
21 |   
22 | {props.children} 23 |
24 | ) 25 | 26 | function ReactRefractorDemo() { 27 | const [value, setValue] = React.useState(defaultValue) 28 | const [language, setLanguage] = React.useState(languages[0]) 29 | 30 | return ( 31 |
32 | {/* Input */} 33 |
34 |

35 | Input 36 | 41 |

42 |