├── .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 | [](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 |
` 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 |57 | ) 58 | } 59 | 60 | ReactDOM.createRoot(document.getElementById('root')!).render( 61 |34 |44 | 45 | {/* Output */} 46 |35 | Input 36 | 41 |
42 |47 |56 |Output
48 |49 |55 |54 | 62 | , 64 | ) 65 | 66 | // Hiding this ugliness down here. 67 | function getDefaultValue() { 68 | return [ 69 | "'use strict'\n", 70 | 71 | 'function longMoo(count) {', 72 | ' if (count < 1) {', 73 | " return ''", 74 | ' }\n', 75 | 76 | " var result = '', pattern = 'oO0o'", 77 | ' while (count > 1) {', 78 | ' if (count & 1) {', 79 | ' result += pattern', 80 | ' }\n', 81 | 82 | ' count >>= 1, pattern += pattern', 83 | ' }\n', 84 | 85 | " return 'M' + result + pattern", 86 | '}\n', 87 | 88 | 'console.log(longMoo(5))', 89 | '// "MoO0ooO0ooO0ooO0ooO0o"', 90 | ].join('\n') 91 | } 92 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |63 | react-refractor demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package-config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | dist: 'dist', 5 | 6 | extract: { 7 | rules: { 8 | // Disable rules for now 9 | 'ae-forgotten-export': 'off', 10 | 'ae-incompatible-release-tags': 'off', 11 | 'ae-internal-missing-underscore': 'off', 12 | 'ae-missing-release-tag': 'off', 13 | }, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-refractor", 3 | "version": "3.1.1", 4 | "description": "Super-thin React wrapper for refractor (Syntax highlighting using VDOM)", 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "source": "./src/index.ts", 9 | "import": "./dist/index.js", 10 | "default": "./dist/index.js" 11 | }, 12 | "./package.json": "./package.json" 13 | }, 14 | "main": "./dist/index.js", 15 | "types": "./dist/index.d.ts", 16 | "browserslist": [ 17 | "fully supports es6-module", 18 | "node 18" 19 | ], 20 | "sideEffects": false, 21 | "files": [ 22 | "dist", 23 | "src" 24 | ], 25 | "scripts": { 26 | "build": "npm run clean && pkg-utils build --strict && npm run build:demo", 27 | "build:demo": "vite build", 28 | "clean": "rimraf dist", 29 | "coverage": "vitest --coverage", 30 | "dev": "vite demo", 31 | "lint": "eslint .", 32 | "prepublishOnly": "npm run build && npm test", 33 | "posttest": "npm run lint", 34 | "test": "vitest && pkg-utils --strict" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/rexxars/react-refractor.git" 39 | }, 40 | "keywords": [ 41 | "react", 42 | "highlight", 43 | "syntax", 44 | "refractor", 45 | "vdom" 46 | ], 47 | "author": "Espen Hovlandsdal", 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/rexxars/react-refractor/issues" 51 | }, 52 | "homepage": "https://github.com/rexxars/react-refractor#readme", 53 | "dependencies": { 54 | "refractor": "^4.8.1", 55 | "unist-util-filter": "^5.0.1", 56 | "unist-util-visit-parents": "^6.0.1" 57 | }, 58 | "devDependencies": { 59 | "@babel/cli": "^7.24.1", 60 | "@babel/core": "^7.24.4", 61 | "@babel/preset-env": "^7.24.4", 62 | "@sanity/pkg-utils": "^6.1.0", 63 | "@sanity/semantic-release-preset": "^4.0.2", 64 | "@types/react": "^18.2.74", 65 | "@types/react-dom": "^18.2.24", 66 | "@typescript-eslint/eslint-plugin": "^6.7.2", 67 | "@typescript-eslint/parser": "^6.7.2", 68 | "@vitejs/plugin-react": "^4.2.1", 69 | "eslint": "^8.49.0", 70 | "eslint-config-prettier": "^9.0.0", 71 | "eslint-config-sanity": "^6.0.0", 72 | "prettier": "^3.2.5", 73 | "react": "^18.0.0", 74 | "react-dom": "^18.0.0", 75 | "rimraf": "^5.0.5", 76 | "semantic-release": "^23.0.7", 77 | "typescript": "5.4.2", 78 | "vite": "^5.2.8", 79 | "vitest": "^1.4.0" 80 | }, 81 | "peerDependencies": { 82 | "react": ">=18.0.0" 83 | }, 84 | "engines": { 85 | "node": ">=18.0.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Refractor.tsx: -------------------------------------------------------------------------------- 1 | import type {HTMLAttributes} from 'react' 2 | import type {Syntax} from 'refractor' 3 | import {refractor as fract} from 'refractor/lib/core.js' 4 | import {addMarkers} from './addMarkers.js' 5 | import {mapWithDepth} from './mapChildren.js' 6 | import type {RefractorProps} from './types.js' 7 | 8 | const DEFAULT_CLASSNAME = 'refractor' 9 | 10 | /** 11 | * @public 12 | */ 13 | export function Refractor(props: RefractorProps) { 14 | const className = props.className || DEFAULT_CLASSNAME 15 | const langClassName = `language-${props.language}` 16 | const codeProps: HTMLAttributes = {className: langClassName} 17 | const preClass = [className, langClassName].filter(Boolean).join(' ') 18 | 19 | if (props.inline) { 20 | codeProps.style = {display: 'inline'} 21 | codeProps.className = className 22 | } 23 | 24 | if (props.plainText) { 25 | const code = {props.value}
26 | return props.inline ? code :{code}27 | } 28 | 29 | let ast = fract.highlight(props.value, props.language) 30 | if (props.markers && props.markers.length > 0) { 31 | ast = addMarkers(ast, {markers: props.markers}) 32 | } 33 | 34 | const value = ast.children.length === 0 ? props.value : ast.children.map(mapWithDepth(0)) 35 | 36 | const code ={value}
37 | return props.inline ? code :{code}38 | } 39 | 40 | /** 41 | * @public 42 | */ 43 | export const registerLanguage = (lang: Syntax) => fract.register(lang) 44 | 45 | /** 46 | * @public 47 | */ 48 | export const hasLanguage = (lang: string) => fract.registered(lang) 49 | -------------------------------------------------------------------------------- /src/addMarkers.ts: -------------------------------------------------------------------------------- 1 | import type {RefractorElement, RefractorRoot, Text} from 'refractor' 2 | import {filter} from 'unist-util-filter' 3 | import {visitParents} from 'unist-util-visit-parents' 4 | 5 | import type {Marker} from './types.js' 6 | 7 | interface Node { 8 | type: string 9 | data?: object | undefined 10 | } 11 | 12 | export function addMarkers( 13 | ast: RefractorRoot, 14 | options: {markers: (Marker | number)[]}, 15 | ): RefractorRoot { 16 | const markers = options.markers 17 | .map((marker) => (typeof marker === 'number' ? {line: marker} : marker)) 18 | .sort((nodeA, nodeB) => nodeA.line - nodeB.line) 19 | 20 | const numbered = lineNumberify(ast.children).nodes 21 | if (markers.length === 0 || numbered.length === 0) { 22 | return {...ast, children: numbered} 23 | } 24 | 25 | return wrapLines(numbered, markers, options) 26 | } 27 | 28 | function lineNumberify(ast: (RefractorElement | Text)[], context = {lineNumber: 1}) { 29 | const resultNodes: (RefractorElement | Text)[] = [] 30 | return ast.reduce( 31 | (result, node) => { 32 | const lineStart = context.lineNumber 33 | 34 | if (node.type === 'text') { 35 | if (node.value.indexOf('\n') === -1) { 36 | setLineInfo(node, lineStart, lineStart) 37 | result.nodes.push(node) 38 | return result 39 | } 40 | 41 | const lines = node.value.split('\n') 42 | for (let i = 0; i < lines.length; i++) { 43 | const lineNum = i === 0 ? context.lineNumber : ++context.lineNumber 44 | result.nodes.push( 45 | setLineInfo( 46 | { 47 | type: 'text', 48 | value: i === lines.length - 1 ? lines[i] : `${lines[i]}\n`, 49 | }, 50 | lineNum, 51 | lineNum, 52 | ), 53 | ) 54 | } 55 | 56 | result.lineNumber = context.lineNumber 57 | return result 58 | } 59 | 60 | if (node.children) { 61 | const processed = lineNumberify(node.children, context) 62 | const firstChild = processed.nodes[0] 63 | const lastChild = processed.nodes[processed.nodes.length - 1] 64 | setLineInfo( 65 | node, 66 | firstChild ? getLineStart(firstChild, lineStart) : lineStart, 67 | lastChild ? getLineEnd(lastChild, lineStart) : lineStart, 68 | ) 69 | node.children = processed.nodes 70 | result.lineNumber = processed.lineNumber 71 | result.nodes.push(node) 72 | return result 73 | } 74 | 75 | result.nodes.push(node) 76 | return result 77 | }, 78 | {nodes: resultNodes, lineNumber: context.lineNumber}, 79 | ) 80 | } 81 | 82 | function getLineStart(node: RefractorElement | Text, fallbackLineStart = 1) { 83 | return node.data && typeof node.data.lineStart === 'number' 84 | ? node.data.lineStart 85 | : fallbackLineStart 86 | } 87 | 88 | function getLineEnd(node: RefractorElement | Text, fallbackLineEnd = 1) { 89 | return node.data && typeof node.data.lineEnd === 'number' ? node.data.lineEnd : fallbackLineEnd 90 | } 91 | 92 | function setLineInfo(node: RefractorElement | Text, lineStart: number, lineEnd: number) { 93 | if (!node.data) { 94 | node.data = {} 95 | } 96 | 97 | node.data.lineStart = lineStart 98 | node.data.lineEnd = lineEnd 99 | return node 100 | } 101 | 102 | function unwrapLine(markerLine: number, nodes: (RefractorElement | Text)[]) { 103 | const tree: RefractorRoot = {type: 'root', children: nodes} 104 | 105 | const headMap = new WeakMap() 106 | const lineMap = new WeakMap() 107 | const tailMap = new WeakMap() 108 | const cloned: Node[] = [] 109 | 110 | function addCopy( 111 | map: WeakMap