├── .changeset ├── README.md └── config.json ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── biome.json ├── package.json ├── package ├── CHANGELOG.md ├── README.md ├── package.json ├── src │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ ├── component.test.tsx.snap │ │ │ └── hook.test.tsx.snap │ │ ├── component.test.tsx │ │ ├── hook.test.tsx │ │ ├── line-numbers.test.tsx │ │ ├── multi-theme.test.tsx │ │ ├── performance.bench.ts │ │ ├── rendering-options.test.tsx │ │ ├── test-setup.ts │ │ ├── throttling.test.ts │ │ └── utils.test.ts │ ├── bundles │ │ ├── core.ts │ │ ├── full.ts │ │ └── web.ts │ ├── core.ts │ ├── index.ts │ ├── lib │ │ ├── component.tsx │ │ ├── extended-types.ts │ │ ├── hook.ts │ │ ├── resolvers.ts │ │ ├── styles.css │ │ ├── transformers.ts │ │ ├── types.ts │ │ └── utils.ts │ └── web.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts ├── playground ├── README.md ├── index.html ├── package.json ├── public │ └── favicon.svg ├── src │ ├── App.css │ ├── App.tsx │ ├── Demo.mdx │ ├── assets │ │ ├── bosque.tmLanguage.json │ │ ├── mcfunction.tmLanguage.json │ │ ├── react.svg │ │ └── shikiLogo.svg │ ├── index.css │ ├── main.tsx │ ├── mdx.d.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json └── scripts └── release.mjs /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": [ 4 | "changesets-changelog-clean", 5 | { "repo": "avgvstvs96/react-shiki" } 6 | ], 7 | "commit": false, 8 | "fixed": [], 9 | "linked": [], 10 | "access": "public", 11 | "baseBranch": "main", 12 | "ignore": ["playground"] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [18, 20, 22] 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v4 17 | with: 18 | version: 10.4.0 19 | 20 | - name: Setup Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: pnpm 25 | 26 | - name: Install dependencies 27 | run: pnpm --filter react-shiki install --frozen-lockfile 28 | 29 | 30 | - name: Build 31 | run: pnpm --filter react-shiki build 32 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | *.local.md 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "biomejs.biome" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Bassim Shahidy 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 | package/README.md -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": false 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "correctness": { 11 | "useExhaustiveDependencies": "off", 12 | "useHookAtTopLevel": "warn" 13 | }, 14 | "style": { 15 | "noImplicitBoolean": "off", 16 | "useFragmentSyntax": "warn", 17 | "useNamingConvention": { 18 | "level": "info", 19 | "options": { 20 | "strictCase": false 21 | } 22 | } 23 | }, 24 | "a11y": { 25 | "useSemanticElements": "warn", 26 | "useFocusableInteractive": "warn", 27 | "noLabelWithoutControl": "warn" 28 | }, 29 | "suspicious": { 30 | "noExplicitAny": "off" 31 | } 32 | } 33 | }, 34 | "formatter": { 35 | "enabled": true, 36 | "indentStyle": "space", 37 | "formatWithErrors": true, 38 | "lineWidth": 74 39 | }, 40 | "javascript": { 41 | "formatter": { 42 | "quoteStyle": "single", 43 | "jsxQuoteStyle": "double", 44 | "trailingCommas": "es5" 45 | } 46 | }, 47 | "files": { 48 | "ignore": ["node_modules", "dist", ".vscode"] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "pnpm --stream -r --parallel dev", 7 | "build": "pnpm --stream -r --parallel build", 8 | "package:dev": "pnpm --filter react-shiki dev", 9 | "package:build": "pnpm --filter react-shiki build", 10 | "playground:dev": "pnpm --filter playground dev", 11 | "playground:build": "pnpm --filter playground build", 12 | "test": "pnpm --filter react-shiki test", 13 | "bench": "pnpm --filter react-shiki bench", 14 | "lint": "pnpm --filter react-shiki lint", 15 | "lint:fix": "pnpm --filter react-shiki lint:fix", 16 | "check": "pnpm --filter react-shiki check", 17 | "format": "pnpm --filter react-shiki format", 18 | "changeset": "changeset", 19 | "release": "node scripts/release.mjs" 20 | }, 21 | "devDependencies": { 22 | "@biomejs/biome": "^1.9.4", 23 | "@changesets/cli": "^2.28.1", 24 | "changesets-changelog-clean": "^1.3.0", 25 | "typescript": "^5.8.2" 26 | }, 27 | "packageManager": "pnpm@10.4.0", 28 | "pnpm": { 29 | "onlyBuiltDependencies": [ 30 | "@biomejs/biome", 31 | "esbuild" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-shiki 2 | 3 | ## 0.7.1 4 | 5 | ### Patch Changes 6 | 7 | - Feat: Add support for line numbers with `showLineNumbers` _[`928c69c`](https://github.com/avgvstvs96/react-shiki/commit/928c69c1bfc9f9918fc9ffbe2b85635dd645ce1e) [@AVGVSTVS96](https://github.com/AVGVSTVS96)_ 8 | 9 | ## 0.7.0 10 | 11 | ### Minor Changes 12 | 13 | - Add `react-shiki/core` entrypoint for fine-grained bundle support, as well as `react-shiki/web` for Shiki's smaller web bundle _[`#59`](https://github.com/AVGVSTVS96/react-shiki/pull/59) [`fdf9609`](https://github.com/avgvstvs96/react-shiki/commit/fdf96094691fc78279ac0d6a0c216075b2fc50c6) [@AVGVSTVS96](https://github.com/AVGVSTVS96)_ 14 | 15 | ### Patch Changes 16 | 17 | - Chore: remove minification _[`#58`](https://github.com/AVGVSTVS96/react-shiki/pull/58) [`91a4fa2`](https://github.com/avgvstvs96/react-shiki/commit/91a4fa292b9e04b8184a26cba30d15518365b81e) [@juliusmarminge](https://github.com/juliusmarminge)_ 18 | 19 | ## 0.6.0 20 | 21 | ### Minor Changes 22 | 23 | - Improve performance by 5-10% _[`#41`](https://github.com/AVGVSTVS96/react-shiki/pull/41) [`4927f46`](https://github.com/avgvstvs96/react-shiki/commit/4927f46) [@AVGVSTVS96](https://github.com/AVGVSTVS96)_ 24 | - Full support for all Shiki options is now available. _[`#50`](https://github.com/AVGVSTVS96/react-shiki/pull/50) [`b28a1ac`](https://github.com/avgvstvs96/react-shiki/commit/b28a1ac15c3a1f512e44aa44d2b95759c75e3886) [@AVGVSTVS96](https://github.com/AVGVSTVS96)_ 25 | - **Breaking change:** Built-in removal of `tabindex` from code blocks has been removed. By default, code blocks will now be focusable (`tabindex="0"`), aligning with Shiki’s upstream default and WCAG accessibility guidelines. If you want to restore the previous non-focusable behavior, explicitly set `tabindex: -1` in your options. For more details and accessibility context, see References below. 26 | - WCAG 3.1 compliance: scrollable `
` elements should be focusable ([WCAG rule](https://www.w3.org/WAI/standards-guidelines/act/rules/0ssw9k/proposed/))
 27 |   - Rationale and discussion: [shikijs/shiki#428](https://github.com/shikijs/shiki/issues/428)
 28 | 
 29 | ## 0.5.3
 30 | 
 31 | ### Patch Changes
 32 | 
 33 | - Ensure custom languages can be identified by their filetypes _[`#51`](https://github.com/AVGVSTVS96/react-shiki/pull/51) [`ff16138`](https://github.com/avgvstvs96/react-shiki/commit/ff16138151a7faba61489c41934a670dbbce5daa) [@AVGVSTVS96](https://github.com/AVGVSTVS96)_
 34 | 
 35 | ## 0.5.2
 36 | 
 37 | ### Patch Changes
 38 | 
 39 | - Refactor types and add changesets github integration with `changesets-changelog-clean` _[`#37`](https://github.com/AVGVSTVS96/react-shiki/pull/37) [`5b73031`](https://github.com/avgvstvs96/react-shiki/commit/5b73031a7cfb63312354b05a74ef2a19880f5c46) [@AVGVSTVS96](https://github.com/AVGVSTVS96)_
 40 | - Bump Shiki to 3.2.1, html-react-parser to 5.2.3 _[`#39`](https://github.com/AVGVSTVS96/react-shiki/pull/39) [`f5e2fea`](https://github.com/avgvstvs96/react-shiki/commit/f5e2fea1e960254fb33419dbd283c6ecb9a15815) [@renovate](https://github.com/apps/renovate)_
 41 | - Simplify code by using Shiki managed singleton instance, adapt logic _[`#38`](https://github.com/AVGVSTVS96/react-shiki/pull/38) [`daef424`](https://github.com/avgvstvs96/react-shiki/commit/daef424f21ba78a6fdecb9608fa7276b3ff578a9) [@AVGVSTVS96](https://github.com/AVGVSTVS96)_
 42 | - Update dev-dependencies _[`#40`](https://github.com/AVGVSTVS96/react-shiki/pull/40) [`b832131`](https://github.com/avgvstvs96/react-shiki/commit/b83213107992cdd03c44ead954c65043b9897bcf) [@renovate](https://github.com/apps/renovate)_
 43 | 
 44 | ## 0.5.1
 45 | 
 46 | ### Patch Changes
 47 | 
 48 | - 9c5d0dd: export language, theme, and options types
 49 | 
 50 | ## 0.5.0
 51 | 
 52 | ### Minor Changes
 53 | 
 54 | - 5449efe: Add dual/multi theme support
 55 | 
 56 | ## 0.4.1
 57 | 
 58 | ### Patch Changes
 59 | 
 60 | - 25ab014: fix: rerender when theme changes
 61 | 
 62 | ## 0.4.0
 63 | 
 64 | ### Minor Changes
 65 | 
 66 | - d2f57de: Add support for custom languages
 67 | 
 68 | ### Patch Changes
 69 | 
 70 | - 4b8364e: Update Shiki to 3.0
 71 | 
 72 | ## 0.3.0
 73 | 
 74 | ### Minor Changes
 75 | 
 76 | - 623bdc3: Refactored Shiki syntax highlighting implementation to use singleton shorthands for simplified theme/language loading and more reliable resource management.
 77 | - 0e23eaa: feat: Add support for custom Shiki transformers
 78 | 
 79 | ### Patch Changes
 80 | 
 81 | - facf2bc: - Fix boolean attribute error
 82 |   - Improve `isInlineCode` function, achieve parity with `rehypeInlineCodeProperty`
 83 | 
 84 | ## 0.2.4
 85 | 
 86 | ### Patch Changes
 87 | 
 88 | - 0985346: Add new prop `langClassName` for passing classNames to the language span when `showLanguage` is enabled
 89 | - 1d9b95a: Accuartely check inline code with exported rehype inline code plugin, sets inline prop when passed to react markdown
 90 | 
 91 | ## 0.2.3
 92 | 
 93 | ### Patch Changes
 94 | 
 95 | - Add `langStyle` prop, separate from code block's `style`
 96 | - Update README
 97 | 
 98 | ## 0.2.2
 99 | 
100 | ### Patch Changes
101 | 
102 | - Update README
103 | 
104 | ## 0.2.1
105 | 
106 | ### Patch Changes
107 | 
108 | - Update README
109 | 
110 | ## 0.2.0
111 | 
112 | ### Minor Changes
113 | 
114 | - Implement fleshed out solution built in 
115 | 
116 |   - Adds support for custom textmate themes alongside bundled Shiki themes
117 |   - Introduces delay option to throttle highlighting for streamed code updates
118 |   - Uses singleton highlighter instance for better performance
119 |   - Adds comprehensive type definitions with improved language and theme support
120 |   - Enhances error handling with graceful fallbacks for unsupported languages
121 | 
122 | ## 0.1.2
123 | 
124 | ### Patch Changes
125 | 
126 | - 26666da: Update README, package.json, and tsup config
127 | 
128 | ## 0.1.1
129 | 
130 | ### Patch Changes
131 | 
132 | - 536ffc5: Add JSDoc documentation to `ShikiHighlighter` and `useShikiHighlighter`
133 | 
134 |   Update README and demo page in playground
135 | 
136 | ## 0.1.0
137 | 
138 | ### Minor Changes
139 | 
140 | - b8ff50b: Add `addDSefaultStyles` prop to control whether or not the code block is rendered with default styles
141 | 
142 | ### Patch Changes
143 | 
144 | - 9031972: Add changesets cli
145 | 


--------------------------------------------------------------------------------
/package/README.md:
--------------------------------------------------------------------------------
  1 | # 🎨 [react-shiki](https://npmjs.com/react-shiki)
  2 | 
  3 | > [!NOTE]
  4 | > This library is still in development. More features will be implemented, and the API may change.
  5 | > Contributions are welcome!
  6 | 
  7 | A performant client-side syntax highlighting component and hook for React, built with [Shiki](https://shiki.matsu.io/).
  8 | 
  9 | [See the demo page with highlighted code blocks showcasing several Shiki themes!](https://react-shiki.vercel.app/)
 10 | 
 11 | 
 12 | 
 13 | - 🎨 [react-shiki](https://npmjs.com/react-shiki)
 14 |   - [Features](#features)
 15 |   - [Installation](#installation)
 16 |   - [Usage](#usage)
 17 |   - [Bundle Options](#bundle-options)
 18 |   - [Configuration](#configuration)
 19 |     - [Common Configuration Options](#common-configuration-options)
 20 |     - [Component-specific Props](#component-specific-props)
 21 |     - [Multi-theme Support](#multi-theme-support)
 22 |     - [Custom Themes](#custom-themes)
 23 |     - [Custom Languages](#custom-languages)
 24 |       - [Preloading Custom Languages](#preloading-custom-languages)
 25 |     - [Custom Transformers](#custom-transformers)
 26 |     - [Line Numbers](#line-numbers)
 27 |   - [Integration](#integration)
 28 |     - [Integration with react-markdown](#integration-with-react-markdown)
 29 |     - [Handling Inline Code](#handling-inline-code)
 30 |   - [Performance](#performance)
 31 |     - [Throttling Real-time Highlighting](#throttling-real-time-highlighting)
 32 |     - [Streaming and LLM Chat UI](#streaming-and-llm-chat-ui)
 33 |   
 34 | 
 35 | ## Features
 36 | 
 37 | - 🖼️ Provides both a `ShikiHighlighter` component and a `useShikiHighlighter` hook for more flexibility
 38 | - 🔐 Shiki output is processed from HAST directly into React elements, no `dangerouslySetInnerHTML` required
 39 | - 📦 Multiple bundle options: Full bundle (~1.2MB gz), web bundle (~695KB gz), or minimal core bundle for fine-grained bundle control
 40 | - 🖌️ Full support for custom TextMate themes and languages
 41 | - 🔧 Supports passing custom Shiki transformers to the highlighter, in addition to all other options supported by `codeToHast`
 42 | - 🚰 Performant highlighting of streamed code, with optional throttling
 43 | - 📚 Includes minimal default styles for code blocks
 44 | - 🚀 Shiki dynamically imports only the languages and themes used on a page for optimal performance
 45 | - 🖥️ `ShikiHighlighter` component displays a language label for each code block
 46 |   when `showLanguage` is set to `true` (default)
 47 | - 🎨 Customizable styling of generated code blocks and language labels
 48 | - 📏 Optional line numbers with customizable starting number and styling
 49 | 
 50 | ## Installation
 51 | 
 52 | ```bash
 53 | npm i react-shiki
 54 | ```
 55 | 
 56 | ## Usage
 57 | 
 58 | You can use either the `ShikiHighlighter` component or the `useShikiHighlighter` hook to highlight code.
 59 | 
 60 | **Using the Component:**
 61 | 
 62 | ```tsx
 63 | import ShikiHighlighter from "react-shiki";
 64 | 
 65 | function CodeBlock() {
 66 |   return (
 67 |     
 68 |       {code.trim()}
 69 |     
 70 |   );
 71 | }
 72 | ```
 73 | 
 74 | **Using the Hook:**
 75 | 
 76 | ```tsx
 77 | import { useShikiHighlighter } from "react-shiki";
 78 | 
 79 | function CodeBlock({ code, language }) {
 80 |   const highlightedCode = useShikiHighlighter(code, language, "github-dark");
 81 | 
 82 |   return 
{highlightedCode}
; 83 | } 84 | ``` 85 | 86 | ## Bundle Options 87 | `react-shiki`, like `shiki`, offers three entry points to balance convenience and bundle optimization: 88 | 89 | ### `react-shiki` (Full Bundle) 90 | ```tsx 91 | import ShikiHighlighter from 'react-shiki'; 92 | ``` 93 | - **Size**: ~6.4MB minified, 1.2MB gzipped 94 | - **Languages**: All Shiki languages and themes 95 | - **Use case**: Unknown language requirements, maximum language support 96 | - **Setup**: Zero configuration required 97 | 98 | ### `react-shiki/web` (Web Bundle) 99 | ```tsx 100 | import ShikiHighlighter from 'react-shiki/web'; 101 | ``` 102 | - **Size**: ~3.8MB minified, 695KB gzipped 103 | - **Languages**: Web-focused languages (HTML, CSS, JS, TS, JSON, Markdown, Vue, JSX, Svelte) 104 | - **Use case**: Web applications with balanced size/functionality 105 | - **Setup**: Drop-in replacement for main entry point 106 | 107 | ### `react-shiki/core` (Minimal Bundle) 108 | ```tsx 109 | import ShikiHighlighter, { 110 | createHighlighterCore, // re-exported from shiki/core 111 | createOnigurumaEngine, // re-exported from shiki/engine/oniguruma 112 | createJavaScriptRegexEngine, // re-exported from shiki/engine/javascript 113 | } from 'react-shiki/core'; 114 | 115 | // Create custom highlighter with dynamic imports to optimize client-side bundle size 116 | const highlighter = await createHighlighterCore({ 117 | themes: [import('@shikijs/themes/nord')], 118 | langs: [import('@shikijs/langs/typescript')], 119 | engine: createOnigurumaEngine(import('shiki/wasm')) 120 | // or createJavaScriptRegexEngine() 121 | }); 122 | 123 | 124 | {code} 125 | 126 | ``` 127 | - **Size**: Minimal (only what you import) 128 | - **Languages**: User-defined via custom highlighter 129 | - **Use case**: Production apps requiring maximum bundle control 130 | - **Setup**: Requires custom highlighter configuration 131 | - **Engine options**: Choose JavaScript engine (smaller bundle, faster startup) or Oniguruma (WASM, maximum language support) 132 | 133 | ### RegExp Engines 134 | 135 | Shiki offers two built-in engines: 136 | - **Oniguruma** - default, uses the compiled Oniguruma WebAssembly, and offer maximum language support 137 | - **JavaScript** - smaller bundle, faster startup, recommended when running highlighting on the client 138 | 139 | Unlike the Oniguruma engine, the JavaScript engine is [strict by default](https://shiki.style/guide/regex-engines#use-with-unsupported-languages). It will throw an error if it encounters an invalid Oniguruma pattern or a pattern that it cannot convert. If you want best-effort results for unsupported grammars, you can enable the forgiving option to suppress any conversion errors: 140 | 141 | ```tsx 142 | createJavaScriptRegexEngine({ forgiving: true }); 143 | ``` 144 | 145 | See [Shiki - RegExp Engines](https://shiki.style/guide/regex-engines) for more info. 146 | 147 | 148 | ## Configuration 149 | 150 | ### Common Configuration Options 151 | 152 | 153 | | Option | Type | Default | Description | 154 | | ------------------- | ------------------ | --------------- | ----------------------------------------------------------------------------- | 155 | | `code` | `string` | - | Code to highlight | 156 | | `language` | `string \| object` | - | Language to highlight, built-in or custom textmate grammer object | 157 | | `theme` | `string \| object` | `'github-dark'` | Single or multi-theme configuration, built-in or custom textmate theme object | 158 | | `delay` | `number` | `0` | Delay between highlights (in milliseconds) | 159 | | `customLanguages` | `array` | `[]` | Array of custom languages to preload | 160 | | `showLineNumbers` | `boolean` | `false` | Display line numbers alongside code | 161 | | `startingLineNumber` | `number` | `1` | Starting line number when line numbers are enabled | 162 | | `transformers` | `array` | `[]` | Custom Shiki transformers for modifying the highlighting output | 163 | | `cssVariablePrefix` | `string` | `'--shiki'` | Prefix for CSS variables storing theme colors | 164 | | `defaultColor` | `string \| false` | `'light'` | Default theme mode when using multiple themes, can also disable default theme | 165 | | `tabindex` | `number` | `0` | Tab index for the code block | 166 | | `decorations` | `array` | `[]` | Custom decorations to wrap the highlighted tokens with | 167 | | `structure` | `string` | `classic` | The structure of the generated HAST and HTML - `classic` or `inline` | 168 | | [`codeToHastOptions`](https://github.com/shikijs/shiki/blob/main/packages/types/src/options.ts#L121) | - | - | All other options supported by Shiki's `codeToHast` | 169 | 170 | ### Component-specific Props 171 | 172 | The `ShikiHighlighter` component offers minimal built-in styling and customization options out-of-the-box: 173 | 174 | | Prop | Type | Default | Description | 175 | | ------------------ | --------- | ------- | ---------------------------------------------------------- | 176 | | `showLanguage` | `boolean` | `true` | Displays language label in top-right corner | 177 | | `addDefaultStyles` | `boolean` | `true` | Adds minimal default styling to the highlighted code block | 178 | | `as` | `string` | `'pre'` | Component's Root HTML element | 179 | | `className` | `string` | - | Custom class name for the code block | 180 | | `langClassName` | `string` | - | Class name for styling the language label | 181 | | `style` | `object` | - | Inline style object for the code block | 182 | | `langStyle` | `object` | - | Inline style object for the language label | 183 | 184 | ### Multi-theme Support 185 | 186 | To use multiple theme modes, pass an object with your multi-theme configuration to the `theme` prop in the `ShikiHighlighter` component: 187 | 188 | ```tsx 189 | 198 | {code.trim()} 199 | 200 | ``` 201 | 202 | Or, when using the hook, pass it to the `theme` parameter: 203 | 204 | ```tsx 205 | const highlightedCode = useShikiHighlighter( 206 | code, 207 | "tsx", 208 | { 209 | light: "github-light", 210 | dark: "github-dark", 211 | dim: "github-dark-dimmed", 212 | }, 213 | { 214 | defaultColor: "dark", 215 | } 216 | ); 217 | ``` 218 | 219 | See [shiki's documentation](https://shiki.matsu.io/docs/themes) for more information on dual and multi theme support, and for the CSS needed to make the themes reactive to your site's theme. 220 | 221 | ### Custom Themes 222 | 223 | Custom themes can be passed as a TextMate theme in JavaScript object. For example, [it should look like this](https://github.com/antfu/textmate-grammars-themes/blob/main/packages/tm-themes/themes/dark-plus.json). 224 | 225 | ```tsx 226 | import tokyoNight from "../styles/tokyo-night.json"; 227 | 228 | // Component 229 | 230 | {code.trim()} 231 | 232 | 233 | // Hook 234 | const highlightedCode = useShikiHighlighter(code, "tsx", tokyoNight); 235 | ``` 236 | 237 | ### Custom Languages 238 | 239 | Custom languages should be passed as a TextMate grammar in JavaScript object. For example, [it should look like this](https://github.com/shikijs/textmate-grammars-themes/blob/main/packages/tm-grammars/grammars/typescript.json) 240 | 241 | ```tsx 242 | import mcfunction from "../langs/mcfunction.tmLanguage.json"; 243 | 244 | // Component 245 | 246 | {code.trim()} 247 | 248 | 249 | // Hook 250 | const highlightedCode = useShikiHighlighter(code, mcfunction, "github-dark"); 251 | ``` 252 | 253 | #### Preloading Custom Languages 254 | 255 | For dynamic highlighting scenarios where language selection happens at runtime: 256 | 257 | ```tsx 258 | import mcfunction from "../langs/mcfunction.tmLanguage.json"; 259 | import bosque from "../langs/bosque.tmLanguage.json"; 260 | 261 | // Component 262 | 267 | {code.trim()} 268 | 269 | 270 | // Hook 271 | const highlightedCode = useShikiHighlighter(code, "typescript", "github-dark", { 272 | customLanguages: [mcfunction, bosque], 273 | }); 274 | ``` 275 | 276 | ### Custom Transformers 277 | 278 | ```tsx 279 | import { customTransformer } from "../utils/shikiTransformers"; 280 | 281 | // Component 282 | 283 | {code.trim()} 284 | 285 | 286 | // Hook 287 | const highlightedCode = useShikiHighlighter(code, "tsx", "github-dark", { 288 | transformers: [customTransformer], 289 | }); 290 | ``` 291 | 292 | ### Line Numbers 293 | 294 | Display line numbers alongside your code, these are CSS-based 295 | and can be customized with CSS variables: 296 | 297 | ```tsx 298 | // Component 299 | 305 | {code} 306 | 307 | 308 | 314 | {code} 315 | 316 | 317 | // Hook (import 'react-shiki/css' for line numbers to work) 318 | const highlightedCode = useShikiHighlighter(code, "javascript", "github-dark", { 319 | showLineNumbers: true, 320 | startingLineNumber: 0, 321 | }); 322 | ``` 323 | 324 | > [!NOTE] 325 | > When using the hook with line numbers, import the CSS file for the line numbers to work: 326 | > ```tsx 327 | > import 'react-shiki/css'; 328 | > ``` 329 | > Or provide your own CSS counter implementation and styles for `.line-numbers` (line `span`) and `.has-line-numbers` (container `code` element) 330 | 331 | Available CSS variables for customization: 332 | ```css 333 | --line-numbers-foreground: rgba(107, 114, 128, 0.5); 334 | --line-numbers-width: 2ch; 335 | --line-numbers-padding-left: 0ch; 336 | --line-numbers-padding-right: 2ch; 337 | --line-numbers-font-size: inherit; 338 | --line-numbers-font-weight: inherit; 339 | --line-numbers-opacity: 1; 340 | ``` 341 | 342 | You can customize them in your own CSS or by using the style prop on the component: 343 | ```tsx 344 | 353 | {code} 354 | 355 | ``` 356 | 357 | ## Integration 358 | 359 | ### Integration with react-markdown 360 | 361 | Create a component to handle syntax highlighting: 362 | 363 | ```tsx 364 | import ReactMarkdown from "react-markdown"; 365 | import ShikiHighlighter, { isInlineCode } from "react-shiki"; 366 | 367 | const CodeHighlight = ({ className, children, node, ...props }) => { 368 | const code = String(children).trim(); 369 | const match = className?.match(/language-(\w+)/); 370 | const language = match ? match[1] : undefined; 371 | const isInline = node ? isInlineCode(node) : undefined; 372 | 373 | return !isInline ? ( 374 | 375 | {code} 376 | 377 | ) : ( 378 | 379 | {code} 380 | 381 | ); 382 | }; 383 | ``` 384 | 385 | Pass the component to react-markdown as a code component: 386 | 387 | ```tsx 388 | 393 | {markdown} 394 | 395 | ``` 396 | 397 | ### Handling Inline Code 398 | 399 | Prior to `9.0.0`, `react-markdown` exposed the `inline` prop to `code` 400 | components which helped to determine if code is inline. This functionality was 401 | removed in `9.0.0`. For your convenience, `react-shiki` provides two 402 | ways to replicate this functionality and API. 403 | 404 | **Method 1: Using the `isInlineCode` helper:** 405 | 406 | `react-shiki` exports `isInlineCode` which parses the `node` prop from `react-markdown` and identifies inline code by checking for the absence of newline characters: 407 | 408 | ```tsx 409 | import ShikiHighlighter, { isInlineCode } from "react-shiki"; 410 | 411 | const CodeHighlight = ({ className, children, node, ...props }) => { 412 | const match = className?.match(/language-(\w+)/); 413 | const language = match ? match[1] : undefined; 414 | 415 | const isInline = node ? isInlineCode(node) : undefined; 416 | 417 | return !isInline ? ( 418 | 419 | {String(children).trim()} 420 | 421 | ) : ( 422 | 423 | {children} 424 | 425 | ); 426 | }; 427 | ``` 428 | 429 | **Method 2: Using the `rehypeInlineCodeProperty` plugin:** 430 | 431 | `react-shiki` also exports `rehypeInlineCodeProperty`, a rehype plugin that 432 | provides the same API as `react-markdown` prior to `9.0.0`. It reintroduces the 433 | `inline` prop which works by checking if `` is nested within a `
` tag, 
434 | if not, it's considered inline code and the `inline` prop is set to `true`.
435 | 
436 | It's passed as a `rehypePlugin` to `react-markdown`:
437 | 
438 | ```tsx
439 | import ReactMarkdown from "react-markdown";
440 | import { rehypeInlineCodeProperty } from "react-shiki";
441 | 
442 | 
448 |   {markdown}
449 | ;
450 | ```
451 | 
452 | Now `inline` can be accessed as a prop in the `code` component:
453 | 
454 | ```tsx
455 | const CodeHighlight = ({
456 |   inline,
457 |   className,
458 |   children,
459 |   node,
460 |   ...props
461 | }: CodeHighlightProps): JSX.Element => {
462 |   const match = className?.match(/language-(\w+)/);
463 |   const language = match ? match[1] : undefined;
464 |   const code = String(children).trim();
465 | 
466 |   return !inline ? (
467 |     
468 |       {code}
469 |     
470 |   ) : (
471 |     
472 |       {code}
473 |     
474 |   );
475 | };
476 | ```
477 | 
478 | ## Performance
479 | 
480 | ### Throttling Real-time Highlighting
481 | 
482 | For improved performance when highlighting frequently changing code:
483 | 
484 | ```tsx
485 | // With the component
486 | 
487 |   {code.trim()}
488 | 
489 | 
490 | // With the hook
491 | const highlightedCode = useShikiHighlighter(code, "tsx", "github-dark", {
492 |   delay: 150,
493 | });
494 | ```
495 | 
496 | ### Streaming and LLM Chat UI
497 | 
498 | `react-shiki` can be used to highlight streamed code from LLM responses in real-time.
499 | 
500 | I use it for an LLM chatbot UI, it renders markdown and highlights
501 | code in memoized chat messages.
502 | 
503 | Using `useShikiHighlighter` hook:
504 | 
505 | ```tsx
506 | import type { ReactNode } from "react";
507 | import { isInlineCode, useShikiHighlighter, type Element } from "react-shiki";
508 | import tokyoNight from "@styles/tokyo-night.mjs";
509 | 
510 | interface CodeHighlightProps {
511 |   className?: string | undefined;
512 |   children?: ReactNode | undefined;
513 |   node?: Element | undefined;
514 | }
515 | 
516 | export const CodeHighlight = ({
517 |   className,
518 |   children,
519 |   node,
520 |   ...props
521 | }: CodeHighlightProps) => {
522 |   const code = String(children).trim();
523 |   const language = className?.match(/language-(\w+)/)?.[1];
524 | 
525 |   const isInline = node ? isInlineCode(node) : false;
526 | 
527 |   const highlightedCode = useShikiHighlighter(code, language, tokyoNight, {
528 |     delay: 150,
529 |   });
530 | 
531 |   return !isInline ? (
532 |     
536 | {language ? ( 537 | 541 | {language} 542 | 543 | ) : null} 544 | {highlightedCode} 545 |
546 | ) : ( 547 | 548 | {children} 549 | 550 | ); 551 | }; 552 | ``` 553 | 554 | Or using the `ShikiHighlighter` component: 555 | 556 | ```tsx 557 | import type { ReactNode } from "react"; 558 | import ShikiHighlighter, { isInlineCode, type Element } from "react-shiki"; 559 | 560 | interface CodeHighlightProps { 561 | className?: string | undefined; 562 | children?: ReactNode | undefined; 563 | node?: Element | undefined; 564 | } 565 | 566 | export const CodeHighlight = ({ 567 | className, 568 | children, 569 | node, 570 | ...props 571 | }: CodeHighlightProps): JSX.Element => { 572 | const match = className?.match(/language-(\w+)/); 573 | const language = match ? match[1] : undefined; 574 | const code = String(children).trim(); 575 | 576 | const isInline: boolean | undefined = node ? isInlineCode(node) : undefined; 577 | 578 | return !isInline ? ( 579 | 585 | {code} 586 | 587 | ) : ( 588 | {code} 589 | ); 590 | }; 591 | ``` 592 | 593 | Passed to `react-markdown` as a `code` component in memoized chat messages: 594 | 595 | ```tsx 596 | const RenderedMessage = React.memo(({ message }: { message: Message }) => ( 597 |
598 | 599 | {message.content} 600 | 601 |
602 | )); 603 | 604 | export const ChatMessages = ({ messages }: { messages: Message[] }) => { 605 | return ( 606 |
607 | {messages.map((message) => ( 608 | 609 | ))} 610 |
611 | ); 612 | }; 613 | ``` 614 | -------------------------------------------------------------------------------- /package/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-shiki", 3 | "description": "Syntax highlighter component for react using shiki", 4 | "version": "0.7.1", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Bassim Shahidy", 8 | "email": "bassim@shahidy.com", 9 | "url": "https://bassim.build" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "directory": "package", 14 | "url": "git+https://github.com/avgvstvs96/react-shiki.git" 15 | }, 16 | "homepage": "https://react-shiki.vercel.app/", 17 | "keywords": [ 18 | "react", 19 | "shiki", 20 | "code", 21 | "syntax", 22 | "highlighter", 23 | "syntax-highlighter", 24 | "react-syntax-highlighter" 25 | ], 26 | "type": "module", 27 | "main": "./dist/index.js", 28 | "types": "./dist/index.d.ts", 29 | "files": [ 30 | "dist", 31 | "src/lib/styles.css" 32 | ], 33 | "exports": { 34 | ".": { 35 | "types": "./dist/index.d.ts", 36 | "default": "./dist/index.js" 37 | }, 38 | "./web": { 39 | "types": "./dist/web.d.ts", 40 | "default": "./dist/web.js" 41 | }, 42 | "./core": { 43 | "types": "./dist/core.d.ts", 44 | "default": "./dist/core.js" 45 | }, 46 | "./css": "./src/lib/styles.css" 47 | }, 48 | "scripts": { 49 | "dev": "tsup --watch", 50 | "build": "tsup", 51 | "test": "vitest", 52 | "bench": "vitest bench", 53 | "lint": "biome lint .", 54 | "lint:fix": "biome lint --write .", 55 | "format": "biome format --write .", 56 | "check": "tsc && biome check ." 57 | }, 58 | "peerDependencies": { 59 | "react": ">= 16.8.0", 60 | "react-dom": ">= 16.8.0" 61 | }, 62 | "dependencies": { 63 | "@types/jest": "^29.5.14", 64 | "clsx": "^2.1.1", 65 | "dequal": "^2.0.3", 66 | "hast-util-to-jsx-runtime": "^2.3.6", 67 | "shiki": "^3.2.1", 68 | "unist-util-visit": "^5.0.0" 69 | }, 70 | "devDependencies": { 71 | "@testing-library/jest-dom": "^6.6.3", 72 | "@testing-library/react": "^16.3.0", 73 | "@types/hast": "^3.0.4", 74 | "@types/node": "22.14.0", 75 | "@types/react": "^19.1.0", 76 | "@vitejs/plugin-react": "^4.3.4", 77 | "benny": "^3.7.1", 78 | "html-react-parser": "^5.2.3", 79 | "jsdom": "^26.0.0", 80 | "tsup": "^8.4.0", 81 | "vitest": "^3.1.1" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /package/src/__tests__/__snapshots__/component.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ShikiHighlighter Component > matches snapshot for complex language input 1`] = ` 4 |
 9 |   
13 |     javascript
14 |   
15 | 
16 | `; 17 | -------------------------------------------------------------------------------- /package/src/__tests__/__snapshots__/hook.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`useShikiHighlighter Hook > matches snapshot for hook rendered output for known language 1`] = ` 4 |
7 | `; 8 | -------------------------------------------------------------------------------- /package/src/__tests__/component.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, waitFor } from '@testing-library/react'; 3 | import { ShikiHighlighter } from '../index'; 4 | import type { ShikiTransformer } from 'shiki'; 5 | 6 | const codeSample = 'console.log("Hello World");'; 7 | 8 | describe('ShikiHighlighter Component', () => { 9 | test('renders language label and highlighted code', async () => { 10 | const { container } = render( 11 | 12 | {codeSample} 13 | 14 | ); 15 | 16 | // Query the language label using its id. 17 | await waitFor(() => { 18 | const langLabel = container.querySelector('#language-label'); 19 | expect(langLabel).toBeInTheDocument(); 20 | expect(langLabel?.textContent).toBe('javascript'); 21 | }); 22 | 23 | // Verify that the highlighted code is rendered. 24 | await waitFor(() => { 25 | const preElement = container.querySelector( 26 | 'pre.shiki.github-light' 27 | ); 28 | expect(preElement).toBeInTheDocument(); 29 | expect(preElement?.textContent).toMatch(/console\.log/); 30 | }); 31 | }); 32 | 33 | test('does not render language label when showLanguage is false', async () => { 34 | const { container } = render( 35 | 40 | {codeSample} 41 | 42 | ); 43 | 44 | await waitFor(() => { 45 | const langLabel = container.querySelector('#language-label'); 46 | expect(langLabel).toBeNull(); 47 | }); 48 | }); 49 | 50 | test('renders with a custom wrapper element when "as" prop is provided', async () => { 51 | const { container } = render( 52 | 57 | {codeSample} 58 | 59 | ); 60 | 61 | await waitFor(() => { 62 | const containerElement = container.querySelector( 63 | '[data-testid="shiki-container"]' 64 | ); 65 | expect(containerElement).toBeInTheDocument(); 66 | expect(containerElement?.tagName.toLowerCase()).toBe('div'); 67 | }); 68 | }); 69 | 70 | test('falls back to plaintext highlighting for unknown languages', async () => { 71 | const unknownLangCode = 'function test() { return true; }'; 72 | const { container } = render( 73 | 74 | {unknownLangCode} 75 | 76 | ); 77 | 78 | await waitFor(() => { 79 | const outerContainer = container.querySelector( 80 | '[data-testid="shiki-container"]' 81 | ); 82 | expect(outerContainer).toBeInTheDocument(); 83 | 84 | const langLabel = outerContainer?.querySelector('#language-label'); 85 | expect(langLabel).toBeInTheDocument(); 86 | expect(langLabel?.textContent).toBe('unknownlang'); 87 | 88 | const preElement = outerContainer?.querySelector( 89 | 'pre.shiki.github-light' 90 | ); 91 | expect(preElement).toBeInTheDocument(); 92 | 93 | const codeElement = preElement?.querySelector('code'); 94 | expect(codeElement).toBeInTheDocument(); 95 | 96 | // Ensure the rendered code exactly matches the input. 97 | expect(preElement?.textContent).toBe(unknownLangCode); 98 | 99 | // Verify no inline-styled spans exist. 100 | const styledSpans = codeElement?.querySelectorAll('span[style]'); 101 | expect(styledSpans?.length).toBe(0); 102 | }); 103 | }); 104 | 105 | test('matches snapshot for complex language input', async () => { 106 | const complexCode = ` 107 | function greet(name) { 108 | console.log('Hello, ' + name); 109 | } 110 | greet('World'); 111 | `.trim(); 112 | 113 | const { container } = render( 114 | 115 | {complexCode} 116 | 117 | ); 118 | 119 | await waitFor(() => { 120 | const outerContainer = container.querySelector( 121 | '[data-testid="shiki-container"]' 122 | ); 123 | expect(outerContainer).toMatchSnapshot(); 124 | }); 125 | }); 126 | 127 | test('applies custom transformers and custom styling props', async () => { 128 | const customCode = 'console.log("Custom transformer test");'; 129 | // Transformer that adds a custom attribute to the
 tag.
130 |     const addDataAttributeTransformer: ShikiTransformer = {
131 |       pre(node) {
132 |         node.properties = {
133 |           ...node.properties,
134 |           'data-custom': 'applied',
135 |         };
136 |       },
137 |     };
138 | 
139 |     const customStyle = { border: '1px solid red' };
140 |     const customLangStyle = { color: 'blue' };
141 | 
142 |     const { container } = render(
143 |       
152 |         {customCode}
153 |       
154 |     );
155 | 
156 |     await waitFor(() => {
157 |       // Check container custom style and class.
158 |       const outerContainer = container.querySelector(
159 |         '[data-testid="shiki-container"]'
160 |       );
161 |       expect(outerContainer).toHaveStyle('border: 1px solid red');
162 |       expect(outerContainer?.className).toContain('custom-code-block');
163 | 
164 |       // Check language label custom style and class.
165 |       const langLabel = outerContainer?.querySelector('#language-label');
166 |       // The computed style for blue is rgb(0, 0, 255).
167 |       expect(langLabel).toHaveStyle('color: rgb(0, 0, 255)');
168 |       expect(langLabel?.className).toContain('custom-lang-label');
169 | 
170 |       // Verify that our custom transformer injected the data attribute on the inner 
.
171 |       const innerPreElement = outerContainer?.querySelector(
172 |         'pre.shiki.github-light'
173 |       );
174 |       expect(innerPreElement).toHaveAttribute('data-custom', 'applied');
175 |     });
176 |   });
177 | });
178 | 


--------------------------------------------------------------------------------
/package/src/__tests__/hook.test.tsx:
--------------------------------------------------------------------------------
  1 | import React from 'react';
  2 | import { render, waitFor } from '@testing-library/react';
  3 | import { useShikiHighlighter } from '../index';
  4 | import type { Language, Theme } from '../lib/types';
  5 | import type { ShikiTransformer } from 'shiki';
  6 | 
  7 | interface TestComponentProps {
  8 |   code: string;
  9 |   language: Language;
 10 |   theme: Theme;
 11 |   transformers?: ShikiTransformer[];
 12 |   tabindex?: string;
 13 | }
 14 | 
 15 | const TestComponent = ({
 16 |   code,
 17 |   language,
 18 |   theme,
 19 |   transformers,
 20 | }: TestComponentProps) => {
 21 |   const highlighted = useShikiHighlighter(code, language, theme, {
 22 |     transformers,
 23 |   });
 24 |   return 
{highlighted}
; 25 | }; 26 | 27 | describe('useShikiHighlighter Hook', () => { 28 | const renderComponent = (props?: Partial) => { 29 | const defaultProps: TestComponentProps = { 30 | code: '
Hello World
', 31 | language: 'html', 32 | theme: 'github-light', 33 | transformers: [], 34 | ...props, 35 | }; 36 | return render(); 37 | }; 38 | 39 | test('renders pre element with correct theme classes', async () => { 40 | const { getByTestId } = renderComponent(); 41 | await waitFor(() => { 42 | const container = getByTestId('highlighted'); 43 | const preElement = container.querySelector( 44 | 'pre.shiki.github-light' 45 | ); 46 | expect(preElement).toBeInTheDocument(); 47 | }); 48 | }); 49 | 50 | test('renders code element inside pre element', async () => { 51 | const { getByTestId } = renderComponent(); 52 | await waitFor(() => { 53 | const container = getByTestId('highlighted'); 54 | const preElement = container.querySelector( 55 | 'pre.shiki.github-light' 56 | ); 57 | const codeElement = preElement?.querySelector('code'); 58 | expect(codeElement).toBeInTheDocument(); 59 | }); 60 | }); 61 | 62 | test('renders line spans inside code element', async () => { 63 | const { getByTestId } = renderComponent(); 64 | await waitFor(() => { 65 | const container = getByTestId('highlighted'); 66 | const preElement = container.querySelector( 67 | 'pre.shiki.github-light' 68 | ); 69 | const codeElement = preElement?.querySelector('code'); 70 | const lineSpan = codeElement?.querySelector('span.line'); 71 | expect(lineSpan).toBeInTheDocument(); 72 | }); 73 | }); 74 | 75 | test('falls back to plaintext highlighting when language is unknown', async () => { 76 | const code = 'function test() { return true; }'; 77 | const { getByTestId } = renderComponent({ 78 | code, 79 | language: 'unknownlang', 80 | }); 81 | await waitFor(() => { 82 | const container = getByTestId('highlighted'); 83 | const preElement = container.querySelector( 84 | 'pre.shiki.github-light' 85 | ); 86 | const codeElement = preElement?.querySelector('code'); 87 | const lineSpan = codeElement?.querySelector('span.line'); 88 | 89 | expect(preElement).toBeInTheDocument(); 90 | expect(codeElement).toBeInTheDocument(); 91 | expect(lineSpan).toBeInTheDocument(); 92 | // The rendered text should exactly match the input. 93 | expect(preElement?.textContent).toBe(code); 94 | // Ensure no inline-styled spans exist. 95 | expect(lineSpan?.querySelectorAll('span[style]').length).toBe(0); 96 | }); 97 | }); 98 | 99 | test('applies custom transformer in useShiki hook', async () => { 100 | const customCode = 'console.log("Test");'; 101 | // Transformer that adds a custom attribute to the
 tag.
102 |     const addDataAttributeTransformer: ShikiTransformer = {
103 |       pre(node) {
104 |         node.properties = {
105 |           ...node.properties,
106 |           'data-custom-transformer': 'applied',
107 |         };
108 |       },
109 |     };
110 | 
111 |     const { getByTestId } = renderComponent({
112 |       code: customCode,
113 |       language: 'javascript',
114 |       transformers: [addDataAttributeTransformer],
115 |     });
116 | 
117 |     await waitFor(() => {
118 |       const container = getByTestId('highlighted');
119 |       const preElement = container.querySelector('pre');
120 |       expect(preElement).toHaveAttribute(
121 |         'data-custom-transformer',
122 |         'applied'
123 |       );
124 |     });
125 |   });
126 | 
127 |   test('matches snapshot for hook rendered output for known language', async () => {
128 |     const code = '
Hello World
'; 129 | const { getByTestId } = renderComponent({ code }); 130 | await waitFor(() => { 131 | const container = getByTestId('highlighted'); 132 | expect(container).toMatchSnapshot(); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /package/src/__tests__/line-numbers.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { render } from '@testing-library/react'; 3 | import { ShikiHighlighter } from '../index'; 4 | 5 | describe('Line Numbers', () => { 6 | const code = `function test() { 7 | return 'hello'; 8 | }`; 9 | 10 | it('should not show line numbers by default', async () => { 11 | const { container } = render( 12 | 13 | {code} 14 | 15 | ); 16 | 17 | // Wait for highlighting to complete 18 | await new Promise((resolve) => setTimeout(resolve, 100)); 19 | 20 | const container_element = container.querySelector('#shiki-container'); 21 | expect(container_element).not.toHaveClass('has-line-numbers'); 22 | 23 | const lineElements = container.querySelectorAll('.line-numbers'); 24 | expect(lineElements).toHaveLength(0); 25 | }); 26 | 27 | it('should show line numbers when enabled', async () => { 28 | const { container } = render( 29 | 34 | {code} 35 | 36 | ); 37 | 38 | // Wait for highlighting to complete 39 | await new Promise((resolve) => setTimeout(resolve, 100)); 40 | 41 | const codeElement = container.querySelector('code'); 42 | expect(codeElement).toHaveClass('has-line-numbers'); 43 | 44 | const lineElements = container.querySelectorAll('.line-numbers'); 45 | expect(lineElements.length).toBeGreaterThan(0); 46 | }); 47 | 48 | it('should set custom starting line number', async () => { 49 | const { container } = render( 50 | 56 | {code} 57 | 58 | ); 59 | 60 | // Wait for highlighting to complete 61 | await new Promise((resolve) => setTimeout(resolve, 100)); 62 | 63 | // Check which elements have the style attribute 64 | const elementsWithStyle = container.querySelectorAll( 65 | '[style*="--line-start"]' 66 | ); 67 | expect(elementsWithStyle.length).toBeGreaterThan(0); 68 | expect(elementsWithStyle[0]?.getAttribute('style')).toContain( 69 | '--line-start: 42' 70 | ); 71 | }); 72 | 73 | it('should not set line-start CSS variable when starting from 1', async () => { 74 | const { container } = render( 75 | 81 | {code} 82 | 83 | ); 84 | 85 | // Wait for highlighting to complete 86 | await new Promise((resolve) => setTimeout(resolve, 100)); 87 | 88 | const elementsWithStyle = container.querySelectorAll( 89 | '[style*="--line-start"]' 90 | ); 91 | expect(elementsWithStyle.length).toBe(0); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /package/src/__tests__/multi-theme.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, waitFor } from '@testing-library/react'; 3 | import { useShikiHighlighter } from '../index'; 4 | 5 | // Test component with configurable options 6 | const TestComponent = ({ 7 | defaultColor, 8 | cssVariablePrefix, 9 | }: { 10 | defaultColor?: string; 11 | cssVariablePrefix?: string; 12 | }) => { 13 | const code = 'console.log("test");'; 14 | const language = 'javascript'; 15 | const themes = { light: 'github-light', dark: 'github-dark' }; 16 | const options = { 17 | ...(defaultColor ? { defaultColor } : {}), 18 | ...(cssVariablePrefix ? { cssVariablePrefix } : {}), 19 | }; 20 | 21 | const highlighted = useShikiHighlighter( 22 | code, 23 | language, 24 | themes, 25 | options 26 | ); 27 | return
{highlighted}
; 28 | }; 29 | 30 | describe('Multi-theme support', () => { 31 | test('when no defaultColor is passed, --shiki-dark should be present', async () => { 32 | const { getByTestId } = render(); 33 | 34 | await waitFor(() => { 35 | const output = getByTestId('output'); 36 | const pre = output.querySelector('pre'); 37 | 38 | // Verify we have a shiki pre element 39 | expect(pre).toBeInTheDocument(); 40 | expect(pre).toHaveClass('shiki'); 41 | 42 | // Find spans with CSS variables 43 | const spans = output.querySelectorAll('span[style*="--shiki-"]'); 44 | expect(spans.length).toBeGreaterThan(0); 45 | 46 | // Check that a span has --shiki-dark variable 47 | const span = spans[0]; 48 | const style = span?.getAttribute('style'); 49 | expect(style).toContain('--shiki-dark'); 50 | }); 51 | }); 52 | 53 | test('when defaultColor is light, --shiki-dark should be present', async () => { 54 | const { getByTestId } = render( 55 | 56 | ); 57 | 58 | await waitFor(() => { 59 | const output = getByTestId('output'); 60 | 61 | // Find spans with CSS variables 62 | const spans = output.querySelectorAll('span[style*="--shiki-"]'); 63 | expect(spans.length).toBeGreaterThan(0); 64 | 65 | // Check that a span has --shiki-dark variable 66 | const span = spans[0]; 67 | const style = span?.getAttribute('style'); 68 | expect(style).toContain('--shiki-dark'); 69 | }); 70 | }); 71 | 72 | test('when defaultColor is dark, --shiki-light should be present', async () => { 73 | const { getByTestId } = render(); 74 | 75 | await waitFor(() => { 76 | const output = getByTestId('output'); 77 | 78 | // Find spans with CSS variables 79 | const spans = output.querySelectorAll('span[style*="--shiki-"]'); 80 | expect(spans.length).toBeGreaterThan(0); 81 | 82 | // Check that a span has --shiki-light variable 83 | const span = spans[0]; 84 | const style = span?.getAttribute('style'); 85 | expect(style).toContain('--shiki-light'); 86 | }); 87 | }); 88 | 89 | test('custom cssVariablePrefix should override default --shiki- prefix', async () => { 90 | const { getByTestId } = render( 91 | 92 | ); 93 | 94 | await waitFor(() => { 95 | const output = getByTestId('output'); 96 | 97 | // Find spans with custom CSS variables 98 | const spans = output.querySelectorAll('span[style*="--custom-"]'); 99 | expect(spans.length).toBeGreaterThan(0); 100 | 101 | // Check that a span has --custom-dark variable and NOT --shiki-dark 102 | const span = spans[0]; 103 | const style = span?.getAttribute('style'); 104 | expect(style).toContain('--custom-dark'); 105 | expect(style).not.toContain('--shiki-dark'); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /package/src/__tests__/performance.bench.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Attribution: Benchmark written by AI. 3 | * 4 | * This benchmark measures the performance of the two main transformation approaches: 5 | * - codeToHast -> toJsxRuntime: Used in the useShikiHighlighter hook 6 | * - codeToHtml -> html-react-parser: An alternative approach 7 | * 8 | * Each benchmark is run with different code sizes and configurations to provide 9 | * a comprehensive view of performance characteristics. 10 | */ 11 | import { describe, bench, beforeAll, afterAll, beforeEach } from 'vitest'; 12 | 13 | import { 14 | getSingletonHighlighter, 15 | type CodeToHastOptions, 16 | type Highlighter, 17 | } from 'shiki'; 18 | import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; 19 | import { toJsxRuntime } from 'hast-util-to-jsx-runtime'; 20 | import htmlReactParser from 'html-react-parser'; 21 | 22 | import type { Language, Theme, Themes } from '../lib/types'; 23 | 24 | import { resolveLanguage, resolveTheme } from '../lib/resolvers'; 25 | // --- Test Data --- 26 | 27 | // Small code sample (few lines) 28 | const smallCodeJS = ` 29 | function hello(name) { 30 | console.log('Hello, ' + name + '!'); 31 | return name; 32 | } 33 | `.trim(); 34 | 35 | // Medium code sample (original JS sample) 36 | const mediumCodeJS = ` 37 | function hello(name) { 38 | console.log('Hello, ' + name + '!'); 39 | const arr = [1, 2, 3, 4, 5]; 40 | const sum = arr.reduce((acc, val) => acc + val, 0); 41 | // This is a simple comment 42 | return { message: \`The sum is \${sum}\`, timestamp: new Date() }; 43 | } 44 | const result = hello('Developer'); 45 | console.log(result); 46 | `.trim(); 47 | 48 | // Large code sample (original TSX sample) 49 | const largeCodeTSX = ` 50 | import React, { useState, useEffect } from 'react'; 51 | 52 | interface MyComponentProps { 53 | initialTitle: string; 54 | items: string[]; 55 | } 56 | 57 | export const MyComponent: React.FC = ({ initialTitle, items }) => { 58 | const [title, setTitle] = useState(initialTitle); 59 | const [count, setCount] = useState(0); 60 | 61 | useEffect(() => { 62 | document.title = \`\${title} - Count: \${count}\`; 63 | }, [title, count]); 64 | 65 | const handleIncrement = () => { 66 | setCount(prev => prev + 1); 67 | }; 68 | 69 | return ( 70 |
71 |

{title}

72 |

Current Count: {count}

73 | 74 |
    75 | {items.map((item, index) =>
  • {item}
  • )} 76 |
77 | {/* Example comment */} 78 | 93 |
94 | ); 95 | }; 96 | `.trim(); 97 | 98 | // Very large code sample (for stress testing) 99 | const veryLargeCodeJS = Array(10).fill(mediumCodeJS).join('\n\n'); 100 | 101 | // --- Test Configurations --- 102 | 103 | // Base configurations for raw transformation benchmarks 104 | const rawTransformConfigs = [ 105 | { 106 | name: 'Small JS - Single Theme', 107 | code: smallCodeJS, 108 | lang: 'javascript', 109 | theme: 'github-dark', 110 | size: 'small', 111 | }, 112 | { 113 | name: 'Medium JS - Single Theme', 114 | code: mediumCodeJS, 115 | lang: 'javascript', 116 | theme: 'github-dark', 117 | size: 'medium', 118 | }, 119 | { 120 | name: 'Large TSX - Single Theme', 121 | code: largeCodeTSX, 122 | lang: 'tsx', 123 | theme: 'github-dark', 124 | size: 'large', 125 | }, 126 | { 127 | name: 'Very Large JS - Single Theme', 128 | code: veryLargeCodeJS, 129 | lang: 'javascript', 130 | theme: 'github-dark', 131 | size: 'very-large', 132 | }, 133 | { 134 | name: 'Medium JS - Multi Theme', 135 | code: mediumCodeJS, 136 | lang: 'javascript', 137 | theme: { light: 'github-light', dark: 'github-dark' }, 138 | size: 'medium', 139 | }, 140 | { 141 | name: 'Large TSX - Multi Theme', 142 | code: largeCodeTSX, 143 | lang: 'tsx', 144 | theme: { light: 'github-light', dark: 'github-dark' }, 145 | size: 'large', 146 | }, 147 | ]; 148 | 149 | // Base options for Shiki 150 | const shikiOptionsBase = {}; 151 | 152 | // --- Benchmark Functions --- 153 | 154 | // Approach A: codeToHast -> toJsxRuntime 155 | async function runApproachA( 156 | highlighter: Highlighter, 157 | code: string, 158 | lang: Language, 159 | theme: Theme | Themes 160 | ) { 161 | const { languageId } = resolveLanguage(lang); 162 | const { isMultiTheme, singleTheme, multiTheme } = resolveTheme(theme); 163 | 164 | const options: CodeToHastOptions = { 165 | ...shikiOptionsBase, 166 | lang: languageId, 167 | ...(isMultiTheme 168 | ? { themes: multiTheme as Themes } 169 | : { theme: singleTheme as Theme }), 170 | }; 171 | 172 | const hast = highlighter.codeToHast(code, options); 173 | const reactNodes = toJsxRuntime(hast, { jsx, jsxs, Fragment }); 174 | return reactNodes; 175 | } 176 | 177 | // Approach B: codeToHtml -> htmlReactParser 178 | async function runApproachB( 179 | highlighter: Highlighter, 180 | code: string, 181 | lang: Language, 182 | theme: Theme | Themes 183 | ) { 184 | const { languageId } = resolveLanguage(lang); 185 | const { isMultiTheme, singleTheme, multiTheme } = resolveTheme(theme); 186 | 187 | const options: CodeToHastOptions = { 188 | ...(shikiOptionsBase as CodeToHastOptions), 189 | lang: languageId, 190 | ...(isMultiTheme 191 | ? { themes: multiTheme as Themes } 192 | : { theme: singleTheme as Theme }), 193 | }; 194 | 195 | const html = highlighter.codeToHtml(code, options); 196 | const reactNodes = htmlReactParser(html); 197 | return reactNodes; 198 | } 199 | 200 | // --- Vitest Benchmark Suite --- 201 | 202 | let highlighterInstance: Highlighter | null = null; 203 | 204 | // Initialize highlighter once before all tests 205 | beforeAll(async () => { 206 | console.log('Initializing Shiki highlighter for benchmarks...'); 207 | try { 208 | // Use raw transformation configs for all tests 209 | const allConfigs = rawTransformConfigs; 210 | 211 | const languagesToLoad = new Set( 212 | allConfigs 213 | .map((c) => resolveLanguage(c.lang).langsToLoad) 214 | .filter(Boolean) 215 | ); 216 | 217 | const themesToLoad = new Set( 218 | allConfigs 219 | .flatMap((c) => resolveTheme(c.theme).themesToLoad) 220 | .filter(Boolean) 221 | ); 222 | 223 | highlighterInstance = await getSingletonHighlighter({ 224 | langs: Array.from(languagesToLoad) as any[], 225 | themes: Array.from(themesToLoad) as Theme[], 226 | }); 227 | 228 | console.log('Highlighter initialized successfully.'); 229 | } catch (error) { 230 | console.error('Failed to initialize Shiki highlighter:', error); 231 | throw new Error(`Shiki initialization failed in beforeAll: ${error}`); 232 | } 233 | }, 30000); 234 | 235 | // Cleanup 236 | afterAll(() => { 237 | highlighterInstance?.dispose(); 238 | highlighterInstance = null; 239 | console.log('Benchmark suite finished.'); 240 | }); 241 | 242 | // Warm-up phase before each benchmark group 243 | beforeEach(() => { 244 | // Run a few iterations to warm up the JIT 245 | if (highlighterInstance) { 246 | for (let i = 0; i < 3; i++) { 247 | highlighterInstance.codeToHast('console.log("warm-up");', { 248 | lang: 'javascript', 249 | theme: 'github-dark', 250 | }); 251 | } 252 | } 253 | }); 254 | 255 | // --- 1. Raw Transformation Benchmarks --- 256 | describe('1. Raw Transformation Performance', () => { 257 | for (const config of rawTransformConfigs) { 258 | describe(`Scenario: ${config.name}`, () => { 259 | // Benchmark Approach A (codeToHast -> toJsxRuntime) 260 | bench( 261 | 'codeToHast -> toJsxRuntime', 262 | async () => { 263 | if (!highlighterInstance) 264 | throw new Error('Highlighter not initialized'); 265 | await runApproachA( 266 | highlighterInstance, 267 | config.code, 268 | config.lang, 269 | config.theme 270 | ); 271 | }, 272 | { 273 | time: 2000, // 2 seconds per benchmark 274 | iterations: config.size === 'very-large' ? 5 : 20, // Fewer iterations for large code 275 | warmupIterations: 3, // Warm up before measuring 276 | } 277 | ); 278 | 279 | // Benchmark Approach B (codeToHtml -> htmlReactParser) 280 | bench( 281 | 'codeToHtml -> html-react-parser', 282 | async () => { 283 | if (!highlighterInstance) 284 | throw new Error('Highlighter not initialized'); 285 | await runApproachB( 286 | highlighterInstance, 287 | config.code, 288 | config.lang, 289 | config.theme 290 | ); 291 | }, 292 | { 293 | time: 2000, 294 | iterations: config.size === 'very-large' ? 5 : 20, 295 | warmupIterations: 3, 296 | } 297 | ); 298 | }); 299 | } 300 | }); 301 | -------------------------------------------------------------------------------- /package/src/__tests__/rendering-options.test.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | /** 4 | * Attribution: AI generated tests 5 | */ 6 | 7 | /** 8 | * These tests are kinda redundant, we only really need to test that options make it to Shiki, 9 | * but these tests were easy enough to generate with AI so no harm in keeping them to be a little 10 | * more thorough. 11 | */ 12 | 13 | import React from 'react'; 14 | import { render, waitFor } from '@testing-library/react'; 15 | import { useShikiHighlighter } from '../index'; 16 | import ShikiHighlighter from '../index'; 17 | 18 | // Hook-based test component 19 | const TestHookComponent = ({ 20 | code, 21 | language, 22 | theme = 'github-light', 23 | mergeWhitespaces, 24 | structure, 25 | decorations, 26 | tokenizeTimeLimit, 27 | tokenizeMaxLineLength, 28 | }) => { 29 | const highlighted = useShikiHighlighter(code, language, theme, { 30 | mergeWhitespaces, 31 | structure, 32 | decorations, 33 | tokenizeTimeLimit, 34 | tokenizeMaxLineLength, 35 | }); 36 | return
{highlighted}
; 37 | }; 38 | 39 | describe('Shiki Rendering Options', () => { 40 | // Helper functions 41 | function countTokens(container) { 42 | return container.querySelectorAll('span:not(.line)').length; 43 | } 44 | 45 | describe('mergeWhitespaces option', () => { 46 | const codeWithSpaces = 'const x = 1;\n\tconst y = 2;'; 47 | 48 | test('default behavior (true) - should merge whitespace tokens', async () => { 49 | const { getByTestId } = render( 50 | 51 | ); 52 | 53 | await waitFor(() => { 54 | const container = getByTestId('highlighted'); 55 | const defaultTokenCount = countTokens(container); 56 | 57 | // Store token count for comparison with other tests 58 | // This is important to ensure we're actually testing different behavior 59 | expect(container.textContent).toContain('const x = 1;'); 60 | 61 | // We need to compare against other test cases, but we can at least 62 | // verify the rendered output contains spans 63 | expect(defaultTokenCount).toBeGreaterThan(0); 64 | }); 65 | }); 66 | 67 | test('explicit true - should merge whitespace tokens', async () => { 68 | const { getByTestId } = render( 69 | 74 | ); 75 | 76 | await waitFor(() => { 77 | const container = getByTestId('highlighted'); 78 | const preElement = container.querySelector('pre'); 79 | expect(preElement).toBeInTheDocument(); 80 | expect(preElement.textContent).toContain('const x = 1;'); 81 | }); 82 | }); 83 | 84 | test('false - should preserve separate whitespace tokens', async () => { 85 | const { getByTestId } = render( 86 | 91 | ); 92 | 93 | await waitFor(() => { 94 | const container = getByTestId('highlighted'); 95 | 96 | // Verify content is preserved 97 | expect(container.textContent).toContain('const x = 1;'); 98 | 99 | // This test would be stronger if we compared token count with the default case, 100 | // but that's hard to do across separate test cases 101 | }); 102 | }); 103 | 104 | test('never - should split whitespace tokens', async () => { 105 | const { getByTestId } = render( 106 | 111 | ); 112 | 113 | await waitFor(() => { 114 | const container = getByTestId('highlighted'); 115 | 116 | // Verify content is preserved 117 | expect(container.textContent).toContain('const x = 1;'); 118 | }); 119 | }); 120 | }); 121 | 122 | describe('structure option', () => { 123 | const sampleCode = 'function test() {\n return true;\n}'; 124 | 125 | test('default classic structure', async () => { 126 | const { getByTestId } = render( 127 | 128 | ); 129 | 130 | await waitFor(() => { 131 | const container = getByTestId('highlighted'); 132 | 133 | // Verify pre > code > span.line > token spans structure 134 | const preElement = container.querySelector('pre'); 135 | expect(preElement).toBeInTheDocument(); 136 | 137 | const codeElement = preElement.querySelector('code'); 138 | expect(codeElement).toBeInTheDocument(); 139 | 140 | const lineElements = codeElement.querySelectorAll('span.line'); 141 | expect(lineElements.length).toBe(3); // Three lines in the code 142 | 143 | // Each line should have token spans 144 | for (const line of lineElements) { 145 | expect(line.querySelectorAll('span').length).toBeGreaterThan(0); 146 | } 147 | }); 148 | }); 149 | 150 | test('inline structure', async () => { 151 | const { getByTestId } = render( 152 | 157 | ); 158 | 159 | await waitFor(() => { 160 | const container = getByTestId('highlighted'); 161 | 162 | // Verify tokens directly under root, with br elements 163 | const preElement = container.querySelector('pre'); 164 | expect(preElement).toBeFalsy(); // No pre element 165 | 166 | const brElements = container.querySelectorAll('br'); 167 | expect(brElements.length).toBe(2); // One br per line break (2 line breaks = 3 lines) 168 | 169 | // Tokens should be direct children of the container 170 | const tokenSpans = container.querySelectorAll('span'); 171 | expect(tokenSpans.length).toBeGreaterThan(5); // Should have multiple token spans 172 | }); 173 | }); 174 | }); 175 | 176 | describe('decorations option', () => { 177 | // Fix: Update the test code to match what's actually being rendered 178 | const decorationCode = 'const x = 1\nconsole.log(x)'; 179 | 180 | test('line-based decorations with line/character positions', async () => { 181 | const decorations = [ 182 | { 183 | start: { line: 0, character: 0 }, 184 | end: { line: 0, character: 11 }, // End of first line 185 | properties: { class: 'highlighted-line' }, 186 | }, 187 | ]; 188 | 189 | const { getByTestId } = render( 190 | 195 | ); 196 | 197 | await waitFor(() => { 198 | const container = getByTestId('highlighted'); 199 | 200 | // Verify highlighted line 201 | const highlightedElement = container.querySelector( 202 | '.highlighted-line' 203 | ); 204 | expect(highlightedElement).toBeInTheDocument(); 205 | expect(highlightedElement.textContent).toBe('const x = 1'); 206 | }); 207 | }); 208 | 209 | test('decorations on second line (as in confirmed example)', async () => { 210 | const decorations = [ 211 | { 212 | start: { line: 1, character: 0 }, 213 | end: { line: 1, character: 11 }, 214 | properties: { class: 'decoration-highlight' }, 215 | }, 216 | ]; 217 | 218 | const { getByTestId } = render( 219 | 224 | ); 225 | 226 | await waitFor(() => { 227 | const container = getByTestId('highlighted'); 228 | 229 | // Verify decoration exists 230 | const decoratedElement = container.querySelector( 231 | '.decoration-highlight' 232 | ); 233 | expect(decoratedElement).toBeInTheDocument(); 234 | 235 | // Check that it contains console.log without expecting the exact full string 236 | expect(decoratedElement.textContent).toContain('console.log'); 237 | }); 238 | }); 239 | 240 | test('token-based decorations with offset positions', async () => { 241 | const decorations = [ 242 | { 243 | start: 6, // Position of 'x' 244 | end: 7, // End of 'x' 245 | properties: { class: 'variable-reference' }, 246 | }, 247 | ]; 248 | 249 | const { getByTestId } = render( 250 | 255 | ); 256 | 257 | await waitFor(() => { 258 | const container = getByTestId('highlighted'); 259 | 260 | // Verify decorated token 261 | const decoratedElement = container.querySelector( 262 | '.variable-reference' 263 | ); 264 | expect(decoratedElement).toBeInTheDocument(); 265 | expect(decoratedElement.textContent).toBe('x'); 266 | }); 267 | }); 268 | 269 | test('overlapping decorations', async () => { 270 | const decorations = [ 271 | { 272 | start: { line: 0, character: 0 }, 273 | end: { line: 0, character: 11 }, 274 | properties: { class: 'decoration-1' }, 275 | }, 276 | { 277 | start: { line: 0, character: 6 }, 278 | end: { line: 0, character: 7 }, 279 | properties: { class: 'decoration-2' }, 280 | }, 281 | ]; 282 | 283 | const { getByTestId } = render( 284 | 289 | ); 290 | 291 | await waitFor(() => { 292 | const container = getByTestId('highlighted'); 293 | 294 | // Verify both decorations applied 295 | const decoration1 = container.querySelector('.decoration-1'); 296 | expect(decoration1).toBeInTheDocument(); 297 | 298 | const decoration2 = container.querySelector('.decoration-2'); 299 | expect(decoration2).toBeInTheDocument(); 300 | expect(decoration2.textContent).toBe('x'); 301 | }); 302 | }); 303 | 304 | test('decorations with custom tag name', async () => { 305 | const decorations = [ 306 | { 307 | start: { line: 0, character: 0 }, 308 | end: { line: 0, character: 11 }, 309 | properties: { class: 'custom-tag' }, 310 | tagName: 'mark', 311 | }, 312 | ]; 313 | 314 | const { getByTestId } = render( 315 | 320 | ); 321 | 322 | await waitFor(() => { 323 | const container = getByTestId('highlighted'); 324 | 325 | // Verify custom tag used 326 | const markElement = container.querySelector('mark.custom-tag'); 327 | expect(markElement).toBeInTheDocument(); 328 | expect(markElement.textContent).toBe('const x = 1'); 329 | }); 330 | }); 331 | 332 | test('decorations with custom transform function', async () => { 333 | const decorations = [ 334 | { 335 | start: { line: 0, character: 0 }, 336 | end: { line: 0, character: 11 }, 337 | properties: { 'data-custom': 'test-value' }, 338 | transform: (el) => { 339 | el.properties['data-transformed'] = 'true'; 340 | return el; 341 | }, 342 | }, 343 | ]; 344 | 345 | const { getByTestId } = render( 346 | 351 | ); 352 | 353 | await waitFor(() => { 354 | const container = getByTestId('highlighted'); 355 | 356 | // Verify transform applied 357 | const decoratedElement = container.querySelector( 358 | '[data-custom="test-value"]' 359 | ); 360 | expect(decoratedElement).toBeInTheDocument(); 361 | expect(decoratedElement.getAttribute('data-transformed')).toBe( 362 | 'true' 363 | ); 364 | }); 365 | }); 366 | }); 367 | }); 368 | -------------------------------------------------------------------------------- /package/src/__tests__/test-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /package/src/__tests__/throttling.test.ts: -------------------------------------------------------------------------------- 1 | import { throttleHighlighting } from '../lib/utils'; 2 | import { vi } from 'vitest'; 3 | 4 | // Test the throttling function directly instead of through the React component 5 | describe('throttleHighlighting', () => { 6 | beforeEach(() => { 7 | vi.useFakeTimers(); 8 | }); 9 | 10 | afterEach(() => { 11 | vi.useRealTimers(); 12 | }); 13 | 14 | test('throttles function calls based on timing', () => { 15 | // Mock date to have a consistent starting point 16 | const originalDateNow = Date.now; 17 | const mockTime = 1000; 18 | Date.now = vi.fn(() => mockTime); 19 | 20 | // Mock the perform highlight function 21 | const performHighlight = vi.fn().mockResolvedValue(undefined); 22 | 23 | // Setup timeout control like in the hook 24 | const timeoutControl = { 25 | current: { 26 | timeoutId: undefined, 27 | nextAllowedTime: 0, 28 | }, 29 | }; 30 | 31 | // First call should schedule immediately since nextAllowedTime is in the past 32 | throttleHighlighting(performHighlight, timeoutControl, 500); 33 | expect(timeoutControl.current.timeoutId).toBeDefined(); 34 | 35 | // Run the timeout 36 | vi.runAllTimers(); 37 | expect(performHighlight).toHaveBeenCalledTimes(1); 38 | expect(timeoutControl.current.nextAllowedTime).toBe(1500); // 1000 + 500 39 | 40 | // Reset the mock 41 | performHighlight.mockClear(); 42 | 43 | // Call again - should be delayed by the throttle duration 44 | throttleHighlighting(performHighlight, timeoutControl, 500); 45 | expect(performHighlight).not.toHaveBeenCalled(); // Not called yet 46 | 47 | // Advance halfway through the delay - should still not be called 48 | vi.advanceTimersByTime(250); 49 | expect(performHighlight).not.toHaveBeenCalled(); 50 | 51 | // Advance the full delay 52 | vi.advanceTimersByTime(250); 53 | expect(performHighlight).toHaveBeenCalledTimes(1); 54 | 55 | // Restore original Date.now 56 | Date.now = originalDateNow; 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /package/src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { isInlineCode, rehypeInlineCodeProperty } from '../lib/utils'; 2 | 3 | describe('isInlineCode', () => { 4 | it('returns true for inline code (no newline in text)', () => { 5 | // Mock a node representing inline code. 6 | const inlineNode = { 7 | children: [ 8 | { type: 'text', value: 'console.log("Hello inline world!");' }, 9 | ], 10 | }; 11 | expect(isInlineCode(inlineNode as any)).toBe(true); 12 | }); 13 | 14 | it('returns false for code fences (text contains newline)', () => { 15 | // Mock a node representing block (fenced) code. 16 | const blockNode = { 17 | children: [ 18 | { 19 | type: 'text', 20 | value: 'console.log("Line 1");\nconsole.log("Line 2");', 21 | }, 22 | ], 23 | }; 24 | expect(isInlineCode(blockNode as any)).toBe(false); 25 | }); 26 | }); 27 | 28 | describe('rehypeInlineCodeProperty', () => { 29 | it('adds the inline property to elements that are not inside
', () => {
30 |     // Mock a tree where a  element is inside a 

. 31 | const tree = { 32 | type: 'root', 33 | children: [ 34 | { 35 | type: 'element', 36 | tagName: 'p', 37 | properties: {}, 38 | children: [ 39 | { 40 | type: 'element', 41 | tagName: 'code', 42 | properties: {}, 43 | children: [{ type: 'text', value: 'inline code' }], 44 | }, 45 | ], 46 | }, 47 | ], 48 | }; 49 | 50 | // Run the plugin. 51 | const plugin = rehypeInlineCodeProperty(); 52 | plugin(tree as any); 53 | 54 | // Locate the element. 55 | const codeElement = tree?.children[0]?.children[0]; 56 | expect(codeElement?.tagName).toBe('code'); 57 | // @ts-expect-error 58 | expect(codeElement?.properties.inline).toBe(true); 59 | }); 60 | 61 | it('does not add the inline property to elements inside

', () => {
62 |     // Simulate a tree where a  element is inside a 
 element.
63 |     const tree = {
64 |       type: 'root',
65 |       children: [
66 |         {
67 |           type: 'element',
68 |           tagName: 'pre',
69 |           properties: {},
70 |           children: [
71 |             {
72 |               type: 'element',
73 |               tagName: 'code',
74 |               properties: {},
75 |               children: [{ type: 'text', value: 'block code' }],
76 |             },
77 |           ],
78 |         },
79 |       ],
80 |     };
81 | 
82 |     // Run the plugin.
83 |     const plugin = rehypeInlineCodeProperty();
84 |     plugin(tree as any);
85 | 
86 |     // Locate the  element inside 
.
87 |     const preElement = tree.children[0];
88 |     const codeElement = preElement?.children[0];
89 |     expect(codeElement?.tagName).toBe('code');
90 |     // Since the code element is inside a 
, it should not have an inline property.
91 |     // @ts-expect-error
92 |     expect(codeElement?.properties.inline).toBeUndefined();
93 |   });
94 | });
95 | 


--------------------------------------------------------------------------------
/package/src/bundles/core.ts:
--------------------------------------------------------------------------------
 1 | import type { HighlighterCore } from 'shiki';
 2 | 
 3 | /**
 4 |  * Validates that a highlighter is provided for the core bundle.
 5 |  * The core bundle requires users to provide their own highlighter instance.
 6 |  */
 7 | export function validateCoreHighlighter(
 8 |   highlighter: HighlighterCore | undefined
 9 | ): HighlighterCore {
10 |   if (!highlighter) {
11 |     throw new Error(
12 |       'react-shiki/core requires a custom highlighter. ' +
13 |         'Use createHighlighterCore() from react-shiki or switch to react-shiki for plug-and-play usage.'
14 |     );
15 |   }
16 |   return highlighter;
17 | }
18 | 


--------------------------------------------------------------------------------
/package/src/bundles/full.ts:
--------------------------------------------------------------------------------
 1 | import { getSingletonHighlighter, type Highlighter } from 'shiki';
 2 | import type { ShikiLanguageRegistration } from '../lib/extended-types';
 3 | import type { Theme } from '../lib/types';
 4 | 
 5 | /**
 6 |  * Creates a highlighter using the full Shiki bundle with all languages and themes.
 7 |  * This is the largest bundle but provides maximum compatibility.
 8 |  */
 9 | export async function createFullHighlighter(
10 |   langsToLoad: ShikiLanguageRegistration,
11 |   themesToLoad: Theme[]
12 | ): Promise {
13 |   try {
14 |     return await getSingletonHighlighter({
15 |       langs: [langsToLoad],
16 |       themes: themesToLoad,
17 |     });
18 |   } catch (error) {
19 |     if (error instanceof Error && error.message.includes('Language')) {
20 |       return await getSingletonHighlighter({
21 |         langs: ['plaintext'],
22 |         themes: themesToLoad,
23 |       });
24 |     }
25 |     throw error;
26 |   }
27 | }
28 | 


--------------------------------------------------------------------------------
/package/src/bundles/web.ts:
--------------------------------------------------------------------------------
 1 | import {
 2 |   getSingletonHighlighter,
 3 |   type Highlighter,
 4 | } from 'shiki/bundle/web';
 5 | import type { ShikiLanguageRegistration } from '../lib/extended-types';
 6 | import type { Theme } from '../lib/types';
 7 | 
 8 | /**
 9 |  * Creates a highlighter using the web Shiki bundle with web-focused languages.
10 |  * Smaller than the full bundle while covering most web development needs.
11 |  * Includes: HTML, CSS, JS, TS, JSON, Markdown, Vue, JSX, Svelte, etc.
12 |  */
13 | export async function createWebHighlighter(
14 |   langsToLoad: ShikiLanguageRegistration,
15 |   themesToLoad: Theme[]
16 | ): Promise {
17 |   try {
18 |     return await getSingletonHighlighter({
19 |       langs: [langsToLoad],
20 |       themes: themesToLoad,
21 |     });
22 |   } catch (error) {
23 |     if (error instanceof Error && error.message.includes('Language')) {
24 |       return await getSingletonHighlighter({
25 |         langs: ['plaintext'],
26 |         themes: themesToLoad,
27 |       });
28 |     }
29 |     throw error;
30 |   }
31 | }
32 | 


--------------------------------------------------------------------------------
/package/src/core.ts:
--------------------------------------------------------------------------------
 1 | import { useShikiHighlighter as useBaseHook } from './lib/hook';
 2 | import { validateCoreHighlighter } from './bundles/core';
 3 | import type { UseShikiHighlighter } from './lib/types';
 4 | 
 5 | export { isInlineCode, rehypeInlineCodeProperty } from './lib/utils';
 6 | 
 7 | import {
 8 |   createShikiHighlighterComponent,
 9 |   type ShikiHighlighterProps,
10 | } from './lib/component';
11 | 
12 | export type { ShikiHighlighterProps };
13 | 
14 | export type {
15 |   UseShikiHighlighter,
16 |   Language,
17 |   Theme,
18 |   Themes,
19 |   Element,
20 |   HighlighterOptions,
21 | } from './lib/types';
22 | 
23 | export { createHighlighterCore } from 'shiki/core';
24 | export { createOnigurumaEngine } from 'shiki/engine/oniguruma';
25 | export { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
26 | 
27 | /**
28 |  * A React hook that provides syntax highlighting using Shiki with a custom highlighter.
29 |  * Requires a highlighter to be provided in options for minimal bundle size.
30 |  *
31 |  * @example
32 |  * ```ts
33 |  * import { createHighlighterCore, createOnigurumaEngine } from 'react-shiki/core';
34 |  *
35 |  * const highlighter = await createHighlighterCore({
36 |  *   themes: [import('@shikijs/themes/nord')],
37 |  *   langs: [import('@shikijs/langs/typescript')],
38 |  *   engine: createOnigurumaEngine(import('shiki/wasm'))
39 |  * });
40 |  *
41 |  * const code = useShikiHighlighter(code, 'typescript', 'nord', { highlighter });
42 |  * ```
43 |  *
44 |  * For plug-and-play usage, consider:
45 |  * - `react-shiki` for full shiki bundle (~6.4MB minified, 1.2MB gzipped)
46 |  * - `react-shiki/web` for smaller shiki web bundle (~3.8MB minified, 695KB gzipped)
47 |  */
48 | export const useShikiHighlighter: UseShikiHighlighter = (
49 |   code,
50 |   lang,
51 |   themeInput,
52 |   options = {}
53 | ) => {
54 |   // Validate that highlighter is provided
55 |   const highlighter = validateCoreHighlighter(options.highlighter);
56 | 
57 |   return useBaseHook(code, lang, themeInput, async () => highlighter, {
58 |     ...options,
59 |     highlighter,
60 |   });
61 | };
62 | 
63 | /**
64 |  * ShikiHighlighter component using a custom highlighter.
65 |  * Requires a highlighter to be provided.
66 |  */
67 | export const ShikiHighlighter = createShikiHighlighterComponent(
68 |   useShikiHighlighter
69 | );
70 | export default ShikiHighlighter;
71 | 


--------------------------------------------------------------------------------
/package/src/index.ts:
--------------------------------------------------------------------------------
 1 | import { useShikiHighlighter as useBaseHook } from './lib/hook';
 2 | import { createFullHighlighter } from './bundles/full';
 3 | import type { UseShikiHighlighter } from './lib/types';
 4 | 
 5 | export { isInlineCode, rehypeInlineCodeProperty } from './lib/utils';
 6 | 
 7 | import {
 8 |   createShikiHighlighterComponent,
 9 |   type ShikiHighlighterProps,
10 | } from './lib/component';
11 | 
12 | export type { ShikiHighlighterProps };
13 | 
14 | export type {
15 |   UseShikiHighlighter,
16 |   Language,
17 |   Theme,
18 |   Themes,
19 |   Element,
20 |   HighlighterOptions,
21 | } from './lib/types';
22 | 
23 | /**
24 |  * A React hook that provides syntax highlighting using Shiki with the full bundle.
25 |  * Includes all languages and themes for maximum compatibility.
26 |  *
27 |  * Bundle size: ~6.4MB minified (1.2MB gzipped)
28 |  *
29 |  * For smaller bundles, consider:
30 |  * - `react-shiki/web` for smaller shiki web bundle (~3.8MB minified, 695KB gzipped)
31 |  * - `react-shiki/core` for custom fine-grained bundle
32 |  */
33 | export const useShikiHighlighter: UseShikiHighlighter = (
34 |   code,
35 |   lang,
36 |   themeInput,
37 |   options = {}
38 | ) => {
39 |   return useBaseHook(
40 |     code,
41 |     lang,
42 |     themeInput,
43 |     createFullHighlighter,
44 |     options
45 |   );
46 | };
47 | 
48 | /**
49 |  * ShikiHighlighter component using the full bundle.
50 |  * Includes all languages and themes for maximum compatibility.
51 |  */
52 | export const ShikiHighlighter = createShikiHighlighterComponent(
53 |   useShikiHighlighter
54 | );
55 | export default ShikiHighlighter;
56 | 


--------------------------------------------------------------------------------
/package/src/lib/component.tsx:
--------------------------------------------------------------------------------
  1 | import './styles.css';
  2 | import { clsx } from 'clsx';
  3 | import { resolveLanguage } from './resolvers';
  4 | 
  5 | import type {
  6 |   HighlighterOptions,
  7 |   Language,
  8 |   Theme,
  9 |   Themes,
 10 | } from './types';
 11 | 
 12 | /**
 13 |  * Props for the ShikiHighlighter component
 14 |  */
 15 | export interface ShikiHighlighterProps extends HighlighterOptions {
 16 |   /**
 17 |    * The programming language for syntax highlighting
 18 |    * Supports custom textmate grammar objects in addition to Shiki's bundled languages
 19 |    * @see https://shiki.style/languages
 20 |    */
 21 |   language: Language;
 22 | 
 23 |   /**
 24 |    * The code to be highlighted
 25 |    */
 26 |   children: string;
 27 | 
 28 |   /**
 29 |    * The color theme or themes for syntax highlighting
 30 |    * Supports single, dual, or multiple themes
 31 |    * Supports custom textmate theme objects in addition to Shiki's bundled themes
 32 |    *
 33 |    * @example
 34 |    * theme='github-dark' // single theme
 35 |    * theme={{ light: 'github-light', dark: 'github-dark' }} // multi-theme
 36 |    *
 37 |    * @see https://shiki.style/themes
 38 |    */
 39 |   theme: Theme | Themes;
 40 | 
 41 |   /**
 42 |    * Controls the application of default styles to the generated code blocks
 43 |    *
 44 |    * Default styles include padding, overflow handling, border radius, language label styling, and font settings
 45 |    * @default true
 46 |    */
 47 |   addDefaultStyles?: boolean;
 48 | 
 49 |   /**
 50 |    * Add custom inline styles to the generated code block
 51 |    */
 52 |   style?: React.CSSProperties;
 53 | 
 54 |   /**
 55 |    * Add custom inline styles to the language label
 56 |    */
 57 |   langStyle?: React.CSSProperties;
 58 | 
 59 |   /**
 60 |    * Add custom CSS class names to the generated code block
 61 |    */
 62 |   className?: string;
 63 | 
 64 |   /**
 65 |    * Add custom CSS class names to the language label
 66 |    */
 67 |   langClassName?: string;
 68 | 
 69 |   /**
 70 |    * Whether to show the language label
 71 |    * @default true
 72 |    */
 73 |   showLanguage?: boolean;
 74 | 
 75 |   /**
 76 |    * Whether to show line numbers
 77 |    * @default false
 78 |    */
 79 |   showLineNumbers?: boolean;
 80 | 
 81 |   /**
 82 |    * Starting line number (when showLineNumbers is true)
 83 |    * @default 1
 84 |    */
 85 |   startingLineNumber?: number;
 86 | 
 87 |   /**
 88 |    * The HTML element that wraps the generated code block.
 89 |    * @default 'pre'
 90 |    */
 91 |   as?: React.ElementType;
 92 | }
 93 | 
 94 | /**
 95 |  * Base ShikiHighlighter component factory.
 96 |  * This creates a component that uses the provided hook implementation.
 97 |  */
 98 | export const createShikiHighlighterComponent = (
 99 |   useShikiHighlighterImpl: (
100 |     code: string,
101 |     lang: Language,
102 |     themeInput: Theme | Themes,
103 |     options?: HighlighterOptions
104 |   ) => React.ReactNode
105 | ) => {
106 |   return ({
107 |     language,
108 |     theme,
109 |     delay,
110 |     transformers,
111 |     defaultColor,
112 |     cssVariablePrefix,
113 |     addDefaultStyles = true,
114 |     style,
115 |     langStyle,
116 |     className,
117 |     langClassName,
118 |     showLanguage = true,
119 |     showLineNumbers = false,
120 |     startingLineNumber = 1,
121 |     children: code,
122 |     as: Element = 'pre',
123 |     customLanguages,
124 |     ...shikiOptions
125 |   }: ShikiHighlighterProps): React.ReactElement => {
126 |     const options: HighlighterOptions = {
127 |       delay,
128 |       transformers,
129 |       customLanguages,
130 |       defaultColor,
131 |       cssVariablePrefix,
132 |       showLineNumbers,
133 |       startingLineNumber,
134 |       ...shikiOptions,
135 |     };
136 | 
137 |     // Use resolveLanguage to get displayLanguageId directly
138 |     const { displayLanguageId } = resolveLanguage(
139 |       language,
140 |       customLanguages
141 |     );
142 | 
143 |     const highlightedCode = useShikiHighlighterImpl(
144 |       code,
145 |       language,
146 |       theme,
147 |       options
148 |     );
149 | 
150 |     return (
151 |       
162 |         {showLanguage && displayLanguageId ? (
163 |           
168 |             {displayLanguageId}
169 |           
170 |         ) : null}
171 |         {highlightedCode}
172 |       
173 |     );
174 |   };
175 | };
176 | 


--------------------------------------------------------------------------------
/package/src/lib/extended-types.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 |  * Attribution:
 3 |  *  This code was written by github:hippotastic in expressive-code/expressive-code
 4 |  *  https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-shiki/src/languages.ts
 5 |  */
 6 | 
 7 | import type {
 8 |   LanguageRegistration as ShikiLanguageRegistration,
 9 |   MaybeGetter,
10 |   MaybeArray,
11 | } from 'shiki/core';
12 | 
13 | // Extract or rebuild non-exported types from Shiki
14 | type IShikiRawRepository = ShikiLanguageRegistration['repository'];
15 | type IShikiRawRule = IShikiRawRepository[keyof IShikiRawRepository];
16 | type ILocation = IShikiRawRepository['$vscodeTextmateLocation'];
17 | 
18 | interface ILocatable {
19 |   readonly $vscodeTextmateLocation?: ILocation;
20 | }
21 | 
22 | // Define modified versions of internal Shiki types that use our less strict `IRawRule`
23 | interface IRawRepositoryMap {
24 |   [name: string]: IRawRule;
25 | }
26 | type IRawRepository = IRawRepositoryMap & ILocatable;
27 | 
28 | interface IRawCapturesMap {
29 |   [captureId: string]: IRawRule;
30 | }
31 | type IRawCaptures = IRawCapturesMap & ILocatable;
32 | 
33 | // Create our less strict version of Shiki's internal `IRawRule` interface
34 | interface IRawRule
35 |   extends Omit<
36 |     IShikiRawRule,
37 |     'applyEndPatternLast' | 'captures' | 'patterns'
38 |   > {
39 |   readonly applyEndPatternLast?: boolean | number;
40 |   readonly captures?: IRawCaptures;
41 |   readonly comment?: string;
42 |   readonly patterns?: IRawRule[];
43 | }
44 | 
45 | /**
46 |  * A less strict version of Shiki's `LanguageRegistration` interface that aligns better with
47 |  * actual grammars found in the wild. This version attempts to reduce the amount
48 |  * of type errors that would occur when importing and adding external grammars,
49 |  * while still being supported by the language processing code.
50 |  */
51 | export interface LanguageRegistration
52 |   extends Omit {
53 |   repository?: IRawRepository;
54 | }
55 | 
56 | export type LanguageInput = MaybeGetter>;
57 | 
58 | export type { ShikiLanguageRegistration };
59 | 


--------------------------------------------------------------------------------
/package/src/lib/hook.ts:
--------------------------------------------------------------------------------
  1 | import {
  2 |   useEffect,
  3 |   useMemo,
  4 |   useRef,
  5 |   useState,
  6 |   type ReactNode,
  7 | } from 'react';
  8 | 
  9 | import { dequal } from 'dequal/lite';
 10 | 
 11 | import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
 12 | import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
 13 | 
 14 | import type {
 15 |   CodeToHastOptions,
 16 |   CodeOptionsSingleTheme,
 17 |   CodeOptionsMultipleThemes,
 18 |   Highlighter,
 19 |   HighlighterCore,
 20 | } from 'shiki';
 21 | 
 22 | import type { ShikiLanguageRegistration } from './extended-types';
 23 | 
 24 | import type {
 25 |   Language,
 26 |   Theme,
 27 |   HighlighterOptions,
 28 |   TimeoutState,
 29 |   Themes,
 30 | } from './types';
 31 | 
 32 | import { throttleHighlighting } from './utils';
 33 | import { resolveLanguage, resolveTheme } from './resolvers';
 34 | import { lineNumbersTransformer } from './transformers';
 35 | 
 36 | const DEFAULT_THEMES: Themes = {
 37 |   light: 'github-light',
 38 |   dark: 'github-dark',
 39 | };
 40 | 
 41 | /**
 42 |  * Returns a deep-stable reference and a version counter that only changes when content changes.
 43 |  * Includes optimizations for primitive values and reference equality.
 44 |  */
 45 | const useStableOptions = (value: T) => {
 46 |   const ref = useRef(value);
 47 |   const revision = useRef(0);
 48 | 
 49 |   // Fast-path for primitive values
 50 |   if (typeof value !== 'object' || value === null) {
 51 |     if (value !== ref.current) {
 52 |       ref.current = value;
 53 |       revision.current += 1;
 54 |     }
 55 |     return [value, revision.current] as const;
 56 |   }
 57 | 
 58 |   // Reference equality check before expensive deep comparison
 59 |   if (value !== ref.current) {
 60 |     if (!dequal(value, ref.current)) {
 61 |       ref.current = value;
 62 |       revision.current += 1;
 63 |     }
 64 |   }
 65 | 
 66 |   return [ref.current, revision.current] as const;
 67 | };
 68 | 
 69 | /**
 70 |  * Base hook for syntax highlighting using Shiki.
 71 |  * This is the core implementation used by all entry points.
 72 |  *
 73 |  * @param code - The code to highlight
 74 |  * @param lang - Language for highlighting
 75 |  * @param themeInput - Theme or themes to use
 76 |  * @param options - Highlighting options
 77 |  * @param createHighlighter - Factory function to create highlighter (internal use)
 78 |  */
 79 | export const useShikiHighlighter = (
 80 |   code: string,
 81 |   lang: Language,
 82 |   themeInput: Theme | Themes,
 83 |   createHighlighter: (
 84 |     langsToLoad: ShikiLanguageRegistration,
 85 |     themesToLoad: Theme[]
 86 |   ) => Promise,
 87 |   options: HighlighterOptions = {}
 88 | ) => {
 89 |   const [highlightedCode, setHighlightedCode] =
 90 |     useState(null);
 91 | 
 92 |   // Stabilize options, language and theme inputs to prevent unnecessary
 93 |   // re-renders or recalculations when object references change
 94 |   const [stableLang, langRev] = useStableOptions(lang);
 95 |   const [stableTheme, themeRev] = useStableOptions(themeInput);
 96 |   const [stableOpts, optsRev] = useStableOptions(options);
 97 | 
 98 |   const { languageId, langsToLoad } = useMemo(
 99 |     () => resolveLanguage(stableLang, stableOpts.customLanguages),
100 |     [stableLang, stableOpts.customLanguages]
101 |   );
102 | 
103 |   const { isMultiTheme, themeId, multiTheme, singleTheme, themesToLoad } =
104 |     useMemo(() => resolveTheme(stableTheme), [stableTheme]);
105 | 
106 |   const timeoutControl = useRef({
107 |     nextAllowedTime: 0,
108 |     timeoutId: undefined,
109 |   });
110 | 
111 |   const shikiOptions = useMemo(() => {
112 |     const languageOption = { lang: languageId };
113 |     const {
114 |       defaultColor,
115 |       cssVariablePrefix,
116 |       showLineNumbers,
117 |       startingLineNumber,
118 |       ...restOptions
119 |     } = stableOpts;
120 | 
121 |     const themeOptions = isMultiTheme
122 |       ? ({
123 |           themes: multiTheme || DEFAULT_THEMES,
124 |           defaultColor,
125 |           cssVariablePrefix,
126 |         } as CodeOptionsMultipleThemes)
127 |       : ({
128 |           theme: singleTheme || DEFAULT_THEMES.dark,
129 |         } as CodeOptionsSingleTheme);
130 | 
131 |     // Add line numbers transformer if enabled
132 |     const transformers = restOptions.transformers || [];
133 |     if (showLineNumbers) {
134 |       transformers.push(lineNumbersTransformer(startingLineNumber));
135 |     }
136 | 
137 |     return {
138 |       ...languageOption,
139 |       ...themeOptions,
140 |       ...restOptions,
141 |       transformers,
142 |     };
143 |   }, [languageId, themeId, langRev, themeRev, optsRev]);
144 | 
145 |   useEffect(() => {
146 |     let isMounted = true;
147 | 
148 |     const highlightCode = async () => {
149 |       if (!languageId) return;
150 | 
151 |       // Use provided custom highlighter or create one using the factory
152 |       const highlighter = stableOpts.highlighter
153 |         ? stableOpts.highlighter
154 |         : await createHighlighter(
155 |             langsToLoad as ShikiLanguageRegistration,
156 |             themesToLoad
157 |           );
158 | 
159 |       // Check if language is loaded, fallback to plaintext if not
160 |       const loadedLanguages = highlighter.getLoadedLanguages();
161 |       const langToUse = loadedLanguages.includes(languageId)
162 |         ? languageId
163 |         : 'plaintext';
164 |       const finalOptions = { ...shikiOptions, lang: langToUse };
165 | 
166 |       const hast = highlighter.codeToHast(code, finalOptions);
167 | 
168 |       if (isMounted) {
169 |         setHighlightedCode(toJsxRuntime(hast, { jsx, jsxs, Fragment }));
170 |       }
171 |     };
172 | 
173 |     const { delay } = stableOpts;
174 | 
175 |     if (delay) {
176 |       throttleHighlighting(highlightCode, timeoutControl, delay);
177 |     } else {
178 |       highlightCode().catch(console.error);
179 |     }
180 | 
181 |     return () => {
182 |       isMounted = false;
183 |       clearTimeout(timeoutControl.current.timeoutId);
184 |     };
185 |   }, [
186 |     code,
187 |     shikiOptions,
188 |     stableOpts.delay,
189 |     stableOpts.highlighter,
190 |     langsToLoad,
191 |     themesToLoad,
192 |   ]);
193 | 
194 |   return highlightedCode;
195 | };
196 | 


--------------------------------------------------------------------------------
/package/src/lib/resolvers.ts:
--------------------------------------------------------------------------------
  1 | import type { Language, Theme, Themes } from './types';
  2 | import type { ThemeRegistrationAny } from 'shiki/core';
  3 | import type { LanguageRegistration } from './extended-types';
  4 | 
  5 | /**
  6 |  * Resolved languages and metadata
  7 |  */
  8 | type LanguageResult = {
  9 |   languageId: string;
 10 |   displayLanguageId: string | null;
 11 |   langsToLoad: Language;
 12 | };
 13 | 
 14 | /**
 15 |  * Resolves the language input to standardized IDs and objects for Shiki and UI display
 16 |  * @param lang The language input from props
 17 |  * @param customLanguages An array of custom textmate grammar objects or a single grammar object
 18 |  * @returns A LanguageResult object containing:
 19 |  *   - languageId: The resolved language ID
 20 |  *   - displayLanguageId: The display language ID
 21 |  *   - langToLoad: The language object or string id to load
 22 |  */
 23 | export const resolveLanguage = (
 24 |   lang: Language,
 25 |   customLanguages?: LanguageRegistration | LanguageRegistration[]
 26 | ): LanguageResult => {
 27 |   const normalizedCustomLangs = customLanguages
 28 |     ? Array.isArray(customLanguages)
 29 |       ? customLanguages
 30 |       : [customLanguages]
 31 |     : [];
 32 | 
 33 |   // Language is null or empty string
 34 |   if (lang == null || (typeof lang === 'string' && !lang.trim())) {
 35 |     return {
 36 |       languageId: 'plaintext',
 37 |       displayLanguageId: 'plaintext',
 38 |       langsToLoad: undefined,
 39 |     };
 40 |   }
 41 | 
 42 |   // Language is custom
 43 |   if (typeof lang === 'object') {
 44 |     return {
 45 |       languageId: lang.name,
 46 |       displayLanguageId: lang.name || null,
 47 |       langsToLoad: lang,
 48 |     };
 49 |   }
 50 | 
 51 |   // Language is string
 52 |   const lowerLang = lang.toLowerCase();
 53 |   const matches = (str: string | undefined): boolean =>
 54 |     str?.toLowerCase() === lowerLang;
 55 | 
 56 |   // Check if the string identifies a provided custom language
 57 |   const customMatch = normalizedCustomLangs.find(
 58 |     (cl) =>
 59 |       matches(cl.name) ||
 60 |       matches(cl.scopeName) ||
 61 |       matches(cl.scopeName?.split('.').pop()) ||
 62 |       cl.aliases?.some(matches) ||
 63 |       cl.fileTypes?.some(matches)
 64 |   );
 65 | 
 66 |   if (customMatch) {
 67 |     return {
 68 |       languageId: customMatch.name || lang,
 69 |       displayLanguageId: lang,
 70 |       langsToLoad: customMatch,
 71 |     };
 72 |   }
 73 | 
 74 |   // For any other string, pass it through,
 75 |   // fallback is handled in highlighter factories
 76 |   return {
 77 |     languageId: lang,
 78 |     displayLanguageId: lang,
 79 |     langsToLoad: lang,
 80 |   };
 81 | };
 82 | 
 83 | /**
 84 |  * Resolved themes and metadata
 85 |  */
 86 | interface ThemeResult {
 87 |   isMultiTheme: boolean;
 88 |   themeId: Theme;
 89 |   multiTheme?: Themes | ThemeRegistrationAny | null;
 90 |   singleTheme?: Theme | undefined;
 91 |   themesToLoad: Theme[];
 92 | }
 93 | 
 94 | /**
 95 |  * Determines theme configuration and returns the resolved theme with metadata
 96 |  * @param themeInput - The theme input, either as a string name or theme object
 97 |  * @returns Object containing:
 98 |  *   - isMultiTheme: If theme input is a multi-theme configuration
 99 |  *   - themeId: Theme reference identifier
100 |  *   - multiTheme: The multi-theme config if it exists
101 |  *   - singleTheme: The single theme if it exists
102 |  *   - themesToLoad: The themes to load when creating the highlighter
103 |  */
104 | export function resolveTheme(themeInput: Theme | Themes): ThemeResult {
105 |   const isTextmateTheme =
106 |     typeof themeInput === 'object' &&
107 |     'tokenColors' in themeInput &&
108 |     Array.isArray(themeInput.tokenColors);
109 | 
110 |   // Assume non textmate objects are multi theme configs
111 |   const isMultiThemeConfig =
112 |     typeof themeInput === 'object' &&
113 |     themeInput !== null &&
114 |     !isTextmateTheme;
115 | 
116 |   const validMultiThemeObj =
117 |     typeof themeInput === 'object' &&
118 |     themeInput !== null &&
119 |     !isTextmateTheme &&
120 |     Object.entries(themeInput).some(
121 |       ([key, value]) =>
122 |         key &&
123 |         value &&
124 |         key.trim() !== '' &&
125 |         value !== '' &&
126 |         (typeof value === 'string' || isTextmateTheme)
127 |     );
128 | 
129 |   if (isMultiThemeConfig) {
130 |     const themeId = validMultiThemeObj
131 |       ? `multi-${Object.values(themeInput)
132 |           .map(
133 |             (theme) =>
134 |               (typeof theme === 'string' ? theme : theme?.name) ||
135 |               'custom'
136 |           )
137 |           .sort()
138 |           .join('-')}`
139 |       : 'multi-default';
140 | 
141 |     // If config is invalid, return null to handle fallback in `buildShikiOptions()`
142 |     return {
143 |       isMultiTheme: true,
144 |       themeId,
145 |       multiTheme: validMultiThemeObj ? themeInput : null,
146 |       themesToLoad: validMultiThemeObj ? Object.values(themeInput) : [],
147 |     };
148 |   }
149 | 
150 |   return {
151 |     isMultiTheme: false,
152 |     themeId:
153 |       typeof themeInput === 'string'
154 |         ? themeInput
155 |         : themeInput?.name || 'custom',
156 |     singleTheme: themeInput,
157 |     themesToLoad: [themeInput],
158 |   };
159 | }
160 | 


--------------------------------------------------------------------------------
/package/src/lib/styles.css:
--------------------------------------------------------------------------------
 1 | .relative {
 2 |   position: relative;
 3 | }
 4 | 
 5 | .defaultStyles pre {
 6 |   overflow: auto;
 7 |   border-radius: 0.5rem;
 8 |   padding-left: 1.5rem;
 9 |   padding-right: 1.5rem;
10 |   padding-top: 1.25rem;
11 |   padding-bottom: 1.25rem;
12 | }
13 | 
14 | .languageLabel {
15 |   position: absolute;
16 |   right: 0.75rem;
17 |   top: 0.5rem;
18 |   font-family: monospace;
19 |   font-size: 0.75rem;
20 |   letter-spacing: -0.05em;
21 |   color: rgba(107, 114, 128, 0.85);
22 | }
23 | 
24 | .line-numbers::before {
25 |   counter-increment: line-number;
26 |   content: counter(line-number);
27 |   display: inline-flex;
28 |   justify-content: flex-end;
29 |   align-items: flex-start;
30 |   box-sizing: content-box;
31 |   min-width: var(--line-numbers-width, 2ch);
32 |   padding-left: var(--line-numbers-padding-left, 2ch);
33 |   padding-right: var(--line-numbers-padding-right, 2ch);
34 |   color: var(--line-numbers-foreground, rgba(107, 114, 128, 0.6));
35 |   font-size: var(--line-numbers-font-size, inherit);
36 |   font-weight: var(--line-numbers-font-weight, inherit);
37 |   line-height: var(--line-numbers-line-height, inherit);
38 |   font-family: var(--line-numbers-font-family, inherit);
39 |   opacity: var(--line-numbers-opacity, 1);
40 |   user-select: none;
41 |   pointer-events: none;
42 | }
43 | 
44 | .has-line-numbers {
45 |   counter-reset: line-number calc(var(--line-start, 1) - 1);
46 |   --line-numbers-foreground: rgba(107, 114, 128, 0.5);
47 |   --line-numbers-width: 2ch;
48 |   --line-numbers-padding-left: 0ch;
49 |   --line-numbers-padding-right: 2ch;
50 |   --line-numbers-font-size: inherit;
51 |   --line-numbers-font-weight: inherit;
52 |   --line-numbers-line-height: inherit;
53 |   --line-numbers-font-family: inherit;
54 |   --line-numbers-opacity: 1;
55 | }
56 | 


--------------------------------------------------------------------------------
/package/src/lib/transformers.ts:
--------------------------------------------------------------------------------
 1 | import type { ShikiTransformer } from 'shiki/core';
 2 | 
 3 | /**
 4 |  * Creates a transformer that enables line numbers display
 5 |  * @param startLine - The starting line number (defaults to 1)
 6 |  */
 7 | export function lineNumbersTransformer(startLine = 1): ShikiTransformer {
 8 |   return {
 9 |     name: 'react-shiki:line-numbers',
10 |     code(node) {
11 |       this.addClassToHast(node, 'has-line-numbers');
12 |       if (startLine !== 1) {
13 |         const existingStyle = (node.properties?.style as string) || '';
14 |         const newStyle = existingStyle
15 |           ? `${existingStyle}; --line-start: ${startLine}`
16 |           : `--line-start: ${startLine}`;
17 |         node.properties = {
18 |           ...node.properties,
19 |           style: newStyle,
20 |         };
21 |       }
22 |     },
23 |     line(node) {
24 |       this.addClassToHast(node, 'line-numbers');
25 |       return node;
26 |     },
27 |   };
28 | }
29 | 


--------------------------------------------------------------------------------
/package/src/lib/types.ts:
--------------------------------------------------------------------------------
  1 | import type {
  2 |   BundledLanguage,
  3 |   SpecialLanguage,
  4 |   BundledTheme,
  5 |   CodeOptionsMultipleThemes,
  6 |   ThemeRegistrationAny,
  7 |   StringLiteralUnion,
  8 |   CodeToHastOptions,
  9 |   Highlighter,
 10 |   HighlighterCore,
 11 | } from 'shiki';
 12 | 
 13 | import type { ReactNode } from 'react';
 14 | 
 15 | import type { LanguageRegistration } from './extended-types';
 16 | 
 17 | import type { Element as HastElement } from 'hast';
 18 | 
 19 | /**
 20 |  * HTML Element, use to type `node` from react-markdown
 21 |  */
 22 | type Element = HastElement;
 23 | 
 24 | /**
 25 |  * A Shiki BundledLanguage or a custom textmate grammar object
 26 |  * @see https://shiki.style/languages
 27 |  */
 28 | type Language =
 29 |   | LanguageRegistration
 30 |   | StringLiteralUnion
 31 |   | undefined;
 32 | 
 33 | /**
 34 |  * A Shiki BundledTheme or a custom textmate theme object
 35 |  * @see https://shiki.style/themes
 36 |  */
 37 | type Theme = ThemeRegistrationAny | StringLiteralUnion;
 38 | 
 39 | /**
 40 |  * A map of color names to themes.
 41 |  * This allows you to specify multiple themes for the generated code.
 42 |  * Supports custom textmate theme objects in addition to Shiki's bundled themes
 43 |  *
 44 |  * @example
 45 |  * ```ts
 46 |  * useShikiHighlighter(code, language, {
 47 |  *   light: 'github-light',
 48 |  *   dark: 'github-dark',
 49 |  *   dim: 'github-dark-dimmed'
 50 |  * })
 51 |  * ```
 52 |  *
 53 |  * @see https://shiki.style/guide/dual-themes
 54 |  */
 55 | type Themes = {
 56 |   [key: string]: ThemeRegistrationAny | StringLiteralUnion;
 57 | };
 58 | 
 59 | /**
 60 |  * Configuration options specific to react-shiki
 61 |  */
 62 | interface ReactShikiOptions {
 63 |   /**
 64 |    * Minimum time (in milliseconds) between highlight operations.
 65 |    * @default undefined (no throttling)
 66 |    */
 67 |   delay?: number;
 68 | 
 69 |   /**
 70 |    * Custom textmate grammars to be preloaded for highlighting.
 71 |    */
 72 |   customLanguages?: LanguageRegistration | LanguageRegistration[];
 73 | 
 74 |   /**
 75 |    * Custom Shiki highlighter instance to use instead of the default one.
 76 |    * Keeps bundle small by only importing specified languages/themes.
 77 |    * Can be either a Highlighter or HighlighterCore instance.
 78 |    *
 79 |    * @example
 80 |    * import {
 81 |    *   createHighlighterCore,
 82 |    *   createOnigurumaEngine,
 83 |    *   useShikiHighlighter
 84 |    * } from "react-shiki";
 85 |    *
 86 |    * const customHighlighter = await createHighlighterCore({
 87 |    *   themes: [
 88 |    *     import('@shikijs/themes/nord')
 89 |    *   ],
 90 |    *   langs: [
 91 |    *     import('@shikijs/langs/javascript'),
 92 |    *     import('@shikijs/langs/typescript')
 93 |    *   ],
 94 |    *   engine: createOnigurumaEngine(import('shiki/wasm'))
 95 |    * });
 96 |    *
 97 |    * const highlightedCode = useShikiHighlighter(code, language, theme, {
 98 |    *   highlighter: customHighlighter,
 99 |    * });
100 |    */
101 |   highlighter?: Highlighter | HighlighterCore;
102 | 
103 |   /**
104 |    * Whether to show line numbers
105 |    * @default false
106 |    */
107 |   showLineNumbers?: boolean;
108 | 
109 |   /**
110 |    * Starting line number (when showLineNumbers is true)
111 |    * @default 1
112 |    */
113 |   startingLineNumber?: number;
114 | }
115 | 
116 | /**
117 |  * Configuration options for the syntax highlighter
118 |  * Extends CodeToHastOptions to allow passing any Shiki options directly
119 |  */
120 | interface HighlighterOptions
121 |   extends ReactShikiOptions,
122 |     Pick<
123 |       CodeOptionsMultipleThemes,
124 |       'defaultColor' | 'cssVariablePrefix'
125 |     >,
126 |     Omit {}
127 | 
128 | /**
129 |  * State for the throttling logic
130 |  */
131 | interface TimeoutState {
132 |   /**
133 |    * Id of the timeout that is currently scheduled
134 |    */
135 |   timeoutId: NodeJS.Timeout | undefined;
136 |   /**
137 |    * Next time when the timeout can be scheduled
138 |    */
139 |   nextAllowedTime: number;
140 | }
141 | 
142 | /**
143 |  * Public API signature for the useShikiHighlighter hook.
144 |  * This ensures all entry points have consistent signatures.
145 |  */
146 | export type UseShikiHighlighter = (
147 |   code: string,
148 |   lang: Language,
149 |   themeInput: Theme | Themes,
150 |   options?: HighlighterOptions
151 | ) => ReactNode;
152 | 
153 | export type {
154 |   Language,
155 |   Theme,
156 |   Themes,
157 |   Element,
158 |   TimeoutState,
159 |   HighlighterOptions,
160 | };
161 | 


--------------------------------------------------------------------------------
/package/src/lib/utils.ts:
--------------------------------------------------------------------------------
 1 | import { visit } from 'unist-util-visit';
 2 | 
 3 | import type { TimeoutState, Element } from './types';
 4 | 
 5 | /**
 6 |  * Rehype plugin to add an 'inline' property to  elements
 7 |  * Sets 'inline' property to true if the  is not within a 
 tag
 8 |  *
 9 |  * Pass this plugin to the `rehypePlugins` prop of react-markdown
10 |  * You can then access `inline` as a prop in components passed to react-markdown
11 |  *
12 |  * @example
13 |  * 
14 |  */
15 | export function rehypeInlineCodeProperty() {
16 |   return (tree: any): undefined => {
17 |     visit(tree, 'element', (node: Element, _index, parent: Element) => {
18 |       if (node.tagName === 'code' && parent.tagName !== 'pre') {
19 |         node.properties.inline = true;
20 |       }
21 |     });
22 |   };
23 | }
24 | 
25 | /**
26 |  * Function to determine if code is inline based on the presence of line breaks
27 |  *
28 |  * @example
29 |  * const isInline = node && isInlineCode(node: Element)
30 |  */
31 | export const isInlineCode = (node: Element): boolean => {
32 |   const textContent = (node.children || [])
33 |     .filter((child) => child.type === 'text')
34 |     .map((child) => child.value)
35 |     .join('');
36 | 
37 |   return !textContent.includes('\n');
38 | };
39 | 
40 | /**
41 |  * Optionally throttles rapid sequential highlighting operations
42 |  * Exported for testing in __tests__/throttleHighlighting.test.ts
43 |  *
44 |  * @example
45 |  * const timeoutControl = useRef({
46 |  *   nextAllowedTime: 0,
47 |  *   timeoutId: undefined
48 |  * });
49 |  *
50 |  * throttleHighlighting(highlightCode, timeoutControl, 1000);
51 |  */
52 | export const throttleHighlighting = (
53 |   performHighlight: () => Promise,
54 |   timeoutControl: React.RefObject,
55 |   throttleMs: number
56 | ) => {
57 |   const now = Date.now();
58 |   clearTimeout(timeoutControl.current.timeoutId);
59 | 
60 |   const delay = Math.max(0, timeoutControl.current.nextAllowedTime - now);
61 |   timeoutControl.current.timeoutId = setTimeout(() => {
62 |     performHighlight().catch(console.error);
63 |     timeoutControl.current.nextAllowedTime = now + throttleMs;
64 |   }, delay);
65 | };
66 | 


--------------------------------------------------------------------------------
/package/src/web.ts:
--------------------------------------------------------------------------------
 1 | import { useShikiHighlighter as useBaseHook } from './lib/hook';
 2 | import { createWebHighlighter } from './bundles/web';
 3 | import type { UseShikiHighlighter } from './lib/types';
 4 | 
 5 | export { isInlineCode, rehypeInlineCodeProperty } from './lib/utils';
 6 | 
 7 | import {
 8 |   createShikiHighlighterComponent,
 9 |   type ShikiHighlighterProps,
10 | } from './lib/component';
11 | 
12 | export type { ShikiHighlighterProps };
13 | 
14 | export type {
15 |   UseShikiHighlighter,
16 |   Language,
17 |   Theme,
18 |   Themes,
19 |   Element,
20 |   HighlighterOptions,
21 | } from './lib/types';
22 | 
23 | /**
24 |  * A React hook that provides syntax highlighting using Shiki with the web bundle.
25 |  * Includes web-focused languages (HTML, CSS, JS, TS, JSON, Markdown, Astro, JSX, Svelte, Vue etc.)
26 |  *
27 |  * Bundle size: ~3.8MB minified (695KB gzipped)
28 |  *
29 |  * For other options, consider:
30 |  * - `react-shiki` for full shiki bundle (~6.4MB minified, 1.2MB gzipped)
31 |  * - `react-shiki/core` for custom fine-grained bundle
32 |  */
33 | export const useShikiHighlighter: UseShikiHighlighter = (
34 |   code,
35 |   lang,
36 |   themeInput,
37 |   options = {}
38 | ) => {
39 |   return useBaseHook(
40 |     code,
41 |     lang,
42 |     themeInput,
43 |     createWebHighlighter,
44 |     options
45 |   );
46 | };
47 | 
48 | /**
49 |  * ShikiHighlighter component using the web bundle.
50 |  * Includes web-focused languages for balanced size and functionality.
51 |  */
52 | export const ShikiHighlighter = createShikiHighlighterComponent(
53 |   useShikiHighlighter
54 | );
55 | export default ShikiHighlighter;
56 | 


--------------------------------------------------------------------------------
/package/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "compilerOptions": {
 3 |     "esModuleInterop": true,
 4 |     "skipLibCheck": true,
 5 |     "module": "Preserve",
 6 |     "moduleResolution": "Bundler",
 7 |     "jsx": "preserve",
 8 |     "noEmit": true,
 9 |     "resolveJsonModule": true,
10 |     "isolatedModules": true,
11 |     "verbatimModuleSyntax": true,
12 |     "strict": true,
13 |     "noUncheckedIndexedAccess": true,
14 |     "noImplicitOverride": true,
15 |     "lib": ["es2022", "dom", "dom.iterable"],
16 |     "baseUrl": ".",
17 |     "paths": {
18 |       "@/*": ["./src/*"]
19 |     },
20 |     "types": ["vitest", "node"]
21 |   },
22 |   "include": ["src/**/*.ts", "src/**/*.tsx"],
23 |   "exclude": ["node_modules"]
24 | }
25 | 


--------------------------------------------------------------------------------
/package/tsup.config.ts:
--------------------------------------------------------------------------------
 1 | import { defineConfig } from 'tsup';
 2 | import { peerDependencies } from './package.json';
 3 | 
 4 | export default defineConfig((options) => {
 5 |   const dev = !!options.watch;
 6 |   return {
 7 |     entry: {
 8 |       index: 'src/index.ts',
 9 |       web: 'src/web.ts',
10 |       core: 'src/core.ts',
11 |     },
12 |     format: ['esm'],
13 |     target: 'es2022',
14 |     dts: true,
15 |     sourcemap: true,
16 |     clean: !dev,
17 |     injectStyle: true,
18 |     external: [...Object.keys(peerDependencies)],
19 | 
20 |     // fixes: React is not defined / JSX runtime not automatically injected
21 |     // https://github.com/egoist/tsup/issues/792#issuecomment-2443773071
22 |     esbuildOptions(options) {
23 |       options.jsx = 'automatic';
24 |     },
25 |   };
26 | });
27 | 


--------------------------------------------------------------------------------
/package/vitest.config.ts:
--------------------------------------------------------------------------------
 1 | import { defineConfig } from 'vitest/config';
 2 | import react from '@vitejs/plugin-react';
 3 | 
 4 | export default defineConfig({
 5 |   plugins: [react()],
 6 |   test: {
 7 |     environment: 'jsdom',
 8 |     globals: true,
 9 |     setupFiles: './src/__tests__/test-setup.ts',
10 |     include: ['src/**/*.{test,spec}.{ts,tsx}'],
11 |   },
12 | });
13 | 


--------------------------------------------------------------------------------
/playground/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', './tsconfig.app.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 | 


--------------------------------------------------------------------------------
/playground/index.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |     
 5 |     
 6 |     
 7 |     react-shiki
 8 |   
 9 |   
10 |     
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "prebuild": "pnpm --filter react-shiki run build", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^19.1.0", 14 | "react-dom": "^19.1.0", 15 | "react-shiki": "workspace:*" 16 | }, 17 | "devDependencies": { 18 | "@mdx-js/react": "^3.1.0", 19 | "@mdx-js/rollup": "^3.1.0", 20 | "@tailwindcss/typography": "^0.5.16", 21 | "@tailwindcss/vite": "^4.1.3", 22 | "@types/react": "^19.1.0", 23 | "@types/react-dom": "^19.1.1", 24 | "@vitejs/plugin-react": "^4.3.4", 25 | "tailwindcss": "^4.1.3", 26 | "vite": "^6.2.5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /playground/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 🎨 3 | -------------------------------------------------------------------------------- /playground/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /playground/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Demo from './Demo.mdx'; 2 | 3 | function App() { 4 | return ( 5 |
6 |
7 | 8 |
9 | Made with ❤ by Bassim -{' '} 10 | @avgvstvs96 11 |
12 |
13 |
14 | ); 15 | } 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /playground/src/Demo.mdx: -------------------------------------------------------------------------------- 1 | import ShikiHighlighter from 'react-shiki'; 2 | import mcfunction from './assets/mcfunction.tmLanguage.json'; 3 | import bosque from './assets/bosque.tmLanguage.json'; 4 | 5 | # 🎨 react-shiki 6 | 7 | Performant client side syntax highlighting component + hook 8 | for react using [Shiki](https://shiki.matsu.io/) 9 | 10 | ## Features 11 | 12 | - 🖼️ Provides both a `ShikiHighlighter` component and a `useShikiHighlighter` hook for more flexibility 13 | - 🔐 No `dangerouslySetInnerHTML` - output from Shiki is parsed using `html-react-parser` 14 | - 📦 Supports all built-in Shiki languages and themes 15 | - 🖌️ Full support for custom TextMate themes and languages 16 | - 🔧 Supports passing custom Shiki transformers to the highlighter 17 | - 🚰 Performant highlighting of streamed code, with optional throttling 18 | - 📚 Includes minimal default styles for code blocks 19 | - 🚀 Shiki dynamically imports only the languages and themes used on a page for optimal performance 20 | - 🖥️ `ShikiHighlighter` component displays a language label for each code block 21 | when `showLanguage` is set to `true` (default) 22 | - 🎨 Customizable styling of generated code blocks and language labels 23 | 24 | ## Installation 25 | 26 | 27 | {`npm install react-shiki`} 28 | 29 | 30 | ## Usage 31 | 32 | ### Basic Usage 33 | 34 | You can use either the `ShikiHighlighter` component or the `useShikiHighlighter` hook to highlight code. 35 | 36 | **Using the Component:** 37 | 38 | 39 | {` 40 | import { ShikiHighlighter } from 'react-shiki'; 41 | 42 | function CodeBlock() { 43 | return ( 44 | 45 | {code.trim()} 46 | 47 | ); 48 | } 49 | `.trim()} 50 | 51 | 52 | The `ShikiHighlighter` component accepts the following props: 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 |
PropTypeDefaultDescription
languagestring-Language of the code to highlight
themestring | object'github-dark'Shiki theme to use
showLanguagebooleantrueShows the language name in the top right corner
addDefaultStylesbooleantrueAdds default styling to the code block
asstring'pre'Root element to render
delaynumber0Delay between highlights in milliseconds
customLanguagesarray-Custom languages to preload
transformersarray-Custom Shiki transformers
classNamestring-Custom class names for the component
langClassNamestring-Class names for the language label
styleobject-Inline style object for the component
langStyleobject-Inline style object for the language label
139 |
140 | 141 | 142 | **Using the Hook:** 143 | 144 | 145 | {` 146 | import { useShikiHighlighter } from 'react-shiki'; 147 | 148 | function CustomCodeBlock({ code, language }) { 149 | const highlightedCode = useShikiHighlighter(code, language, 'github-dark'); 150 | 151 | return
{highlightedCode}
; 152 | } 153 | `.trim()} 154 |
155 | 156 | **The hook accepts the following parameters:** 157 | 158 |
159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 |
ParamTypeDescription
codestringThe code to be highlighted
languagestring | objectThe language for highlighting
themeInputstring | objectThe theme or themes to be used for highlighting
optionsobjectOptional configuration options
190 |
191 | 192 | **`options`:** 193 | 194 |
195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 |
ParamTypeDefaultDescription
delaynumber0 (disabled)The delay between highlights in milliseconds
transformersarray[]Transformers for the Shiki pipeline
customLanguagesarray[]Custom languages to preload
cssVariablePrefixstring'--shiki'Prefix of CSS variables used to store theme colors
defaultColorstring'light'The default theme mode when using multiple themes. Can be set to false to disable the default theme
237 |
238 | 239 | ### Integration with react-markdown 240 | 241 | Create a component to handle syntax highlighting: 242 | 243 | {` 244 | import ReactMarkdown from "react-markdown"; 245 | import { ShikiHighlighter, isInlineCode } from "react-shiki"; 246 | 247 | const CodeHighlight = ({ className, children, node, ...props }) => { 248 | const code = String(children).trim(); 249 | const match = className?.match(/language-(\\w+)/); 250 | const language = match ? match[1] : undefined; 251 | const isInline = node ? isInlineCode(node) : undefined; 252 | 253 | return !isInline ? ( 254 | 255 | {code} 256 | 257 | ) : ( 258 | 259 | {code} 260 | 261 | ); 262 | }; 263 | `.trim()} 264 | 265 | 266 | Pass the component to react-markdown as a code component: 267 | 268 | {` 269 | 274 | {markdown} 275 | 276 | `.trim()} 277 | 278 | 279 | ### Handling Inline Code 280 | 281 | Prior to `9.0.0`, `react-markdown` exposed the `inline` prop to `code` 282 | components which helped to determine if code is inline. This functionality was 283 | removed in `9.0.0`. For your convenience, `react-shiki` provides two 284 | ways to replicate this functionality and API. 285 | 286 | **Method 1: Using the `isInlineCode` helper:** 287 | 288 | `react-shiki` exports `isInlineCode` which parses the `node` prop from `react-markdown` and identifies inline code by checking for the absence of newline characters: 289 | 290 | 291 | {` 292 | import { isInlineCode, ShikiHighlighter } from "react-shiki"; 293 | 294 | const CodeHighlight = ({ className, children, node, ...props }) => { 295 | const match = className?.match(/language-(\\w+)/); 296 | const language = match ? match[1] : undefined; 297 | const isInline = node ? isInlineCode(node) : undefined; 298 | 299 | return !isInline ? ( 300 | 301 | {String(children).trim()} 302 | 303 | ) : ( 304 | 305 | {children} 306 | 307 | ); 308 | }; 309 | `.trim()} 310 | 311 | 312 | **Method 2: Using the rehype plugin:** 313 | 314 | `react-shiki` also exports `rehypeInlineCodeProperty`, a rehype plugin that 315 | provides the same API as `react-markdown` prior to `9.0.0`. It reintroduces the 316 | `inline` prop which works by checking if `` is nested within a `
` tag, 
317 | if not, it's considered inline code and the `inline` prop is set to `true`.
318 | 
319 | It's passed as a rehype plugin to `react-markdown`:
320 | 
321 | 
322 | {`
323 | import ReactMarkdown from "react-markdown";
324 | import { rehypeInlineCodeProperty } from "react-shiki";
325 | 
326 | 
331 |     {markdown}
332 | ;
333 | `.trim()}
334 | 
335 | 
336 | Now `inline` can be accessed as a prop in the `CodeHighlight` component:
337 | 
338 | 
339 | {`
340 | const CodeHighlight = ({
341 |     inline,
342 |     className,
343 |     children,
344 |     node,
345 |     ...props
346 | }: CodeHighlightProps): JSX.Element => {
347 |     const match = className?.match(/language-(\\w+)/);
348 |     const language = match ? match[1] : undefined;
349 |     const code = String(children).trim();
350 | 
351 |     return !inline ? (
352 |         
353 |             {code}
354 |         
355 |     ) : (
356 |         
357 |             {code}
358 |         
359 |     );
360 | };
361 | `.trim()}
362 | 
363 | 
364 | ### Multi-theme Support
365 | 
366 | 
371 |   {`
372 | 
381 |     {code.trim()}
382 | 
383 | 
384 | // Using the hook
385 | const highlightedCode = useShikiHighlighter(
386 |     code,
387 |     "tsx",
388 |     { 
389 |         light: "github-light",
390 |         dark: "github-dark",
391 |         dim: "github-dark-dimmed"
392 |     },
393 |     {
394 |         defaultColor: "dark",
395 |     }
396 | );
397 | `.trim()}
398 | 
399 | 
400 | See [shiki's documentation](https://shiki.matsu.io/docs/themes) for more information on dual and multi theme support, and for the CSS needed to make the themes reactive to your site's theme.
401 | 
402 | ### Custom Themes
403 | Pass custom TextMate themes as a JSON object:
404 | 
405 | 
406 | {`
407 | import tokyoNight from "../styles/tokyo-night.json";
408 | 
409 | // Using the component
410 | 
411 |     {code.trim()}
412 | 
413 | 
414 | // Using the hook
415 | const highlightedCode = useShikiHighlighter(code, "tsx", tokyoNight);
416 | `.trim()}
417 | 
418 | 
419 | ### Custom Languages
420 | Pass custom TextMate languages as a JSON object:
421 | 
422 | 
423 | {`
424 | import mcfunction from "../langs/mcfunction.tmLanguage.json";
425 | 
426 | // Using the component
427 | 
428 |     {code.trim()}
429 | 
430 | 
431 | // Using the hook
432 | const highlightedCode = useShikiHighlighter(code, mcfunction, "github-dark");
433 | `.trim()}
434 | 
435 | 
436 | ### Custom Language Examples
437 | 
438 | **Mcfunction**:
439 | 
440 |   {`
441 | tag @e[tag=mcscriptTags] add isCool
442 | tag @e[tag=mcscriptTags] remove isCool
443 | execute if entity @e[tag=mcscriptTags,tag=isCool] run say he is cool
444 | 
445 | tag @s add isBad
446 | tag @s remove isBad
447 | execute if entity @s[tag=isBad] run say he is bad
448 |   `.trim()}
449 | 
450 | 
451 | **Bosque**:
452 | 
453 |   {`
454 | function sign(x?: Int=0i): Int {
455 |     var y: Int;
456 | 
457 |     if(x == 0i) {
458 |         y = 0i;
459 |     }
460 |     else {
461 |         y = (x > 0i) ? 1i : -1i;
462 |     }
463 | 
464 |     return y;
465 | }
466 | 
467 | sign(5i)    //1
468 | sign(-5i)   //-1
469 | sign()     //0
470 |   `.trim()}
471 | 
472 | 
473 | #### Preloading Custom Languages
474 | For dynamic highlighting scenarios where language selection happens at runtime:
475 | 
476 | 
477 | {`
478 | import mcfunction from "../langs/mcfunction.tmLanguage.json";
479 | import bosque from "../langs/bosque.tmLanguage.json";
480 | 
481 | // With the component
482 | 
487 |     {code.trim()}
488 | 
489 | 
490 | // With the hook
491 | const highlightedCode = useShikiHighlighter(code, "typescript", "github-dark", {
492 |     customLanguages: [mcfunction, bosque],
493 | });
494 | `.trim()}
495 | 
496 | 
497 | ### Custom Transformers
498 | 
499 | 
500 | {`
501 | import { customTransformer } from "../utils/shikiTransformers";
502 | 
503 | // Using the component
504 | 
505 |     {code.trim()}
506 | 
507 | 
508 | // Using the hook
509 | const highlightedCode = useShikiHighlighter(code, "tsx", "github-dark", {
510 |     transformers: [customTransformer],
511 | });
512 | `.trim()}
513 | 
514 | 
515 | ## Line Numbers
516 | 
517 | Display line numbers alongside your code:
518 | 
519 | 
520 | {`
521 | function fibonacci(n) {
522 |     if (n <= 1) return n;
523 |     return fibonacci(n - 1) + fibonacci(n - 2);
524 | }
525 | 
526 | // Example usage
527 | const result = fibonacci(10);
528 | console.log(result); // 55
529 | `.trim()}
530 | 
531 | 
532 | With custom starting line number:
533 | 
534 | 
535 | {`
536 | def calculate_area(radius):
537 |     """Calculate the area of a circle."""
538 |     import math
539 |     return math.pi * radius ** 2
540 | 
541 | # Usage
542 | area = calculate_area(5)
543 | print(f"Area: {area}")
544 | `.trim()}
545 | 
546 | 
547 | Using the hook with line numbers:
548 | 
549 | 
550 | {`
551 | const highlightedCode = useShikiHighlighter(code, "javascript", "github-dark", {
552 |     showLineNumbers: true,
553 |     lineNumbersStart: 1,
554 | });
555 | `.trim()}
556 | 
557 | 
558 | ## Performance
559 | 
560 | ### Throttling Real-time Highlighting
561 | 
562 | For improved performance when highlighting frequently changing code:
563 | 
564 | 
565 | {`
566 | // With the component
567 | 
568 |     {code.trim()}
569 | 
570 | 
571 | // With the hook
572 | const highlightedCode = useShikiHighlighter(code, "tsx", "github-dark", {
573 |     delay: 150,
574 | });
575 | `.trim()}
576 | 
577 | 
578 | ### Streaming and LLM Chat UI
579 | 
580 | `react-shiki` can be used to highlight streamed code from LLM responses in real-time.
581 | 
582 | I use it for an LLM chatbot UI, it renders markdown and highlights
583 | code in memoized chat messages.
584 | 
585 | Using `useShikiHighlighter` hook:
586 | 
587 | 
588 | {`
589 | import type { ReactNode } from "react";
590 | import { isInlineCode, useShikiHighlighter, type Element } from "react-shiki";
591 | import tokyoNight from "@styles/tokyo-night.mjs";
592 | 
593 | interface CodeHighlightProps {
594 |     className?: string | undefined;
595 |     children?: ReactNode | undefined;
596 |     node?: Element | undefined;
597 | }
598 | 
599 | export const CodeHighlight = ({
600 |     className,
601 |     children,
602 |     node,
603 |     ...props
604 | }: CodeHighlightProps) => {
605 |     const code = String(children).trim();
606 |     const language = className?.match(/language-(\\w+)/)?.[1];
607 | 
608 |     const isInline = node ? isInlineCode(node) : false;
609 | 
610 |     const highlightedCode = useShikiHighlighter(code, language, tokyoNight, {
611 |         delay: 150,
612 |     });
613 | 
614 |     return !isInline ? (
615 |         
619 | {language ? ( 620 | 624 | {language} 625 | 626 | ) : null} 627 | {highlightedCode} 628 |
629 | ) : ( 630 | 631 | {children} 632 | 633 | ); 634 | }; 635 | `.trim()} 636 |
637 | 638 | Or using the `ShikiHighlighter` component: 639 | 640 | 641 | {` 642 | import type { ReactNode } from "react"; 643 | import ShikiHighlighter, { isInlineCode, type Element } from "react-shiki"; 644 | 645 | interface CodeHighlightProps { 646 | className?: string | undefined; 647 | children?: ReactNode | undefined; 648 | node?: Element | undefined; 649 | } 650 | 651 | export const CodeHighlight = ({ 652 | className, 653 | children, 654 | node, 655 | ...props 656 | }: CodeHighlightProps): JSX.Element => { 657 | const match = className?.match(/language-(\\w+)/); 658 | const language = match ? match[1] : undefined; 659 | const code = String(children).trim(); 660 | 661 | const isInline: boolean | undefined = node ? isInlineCode(node) : undefined; 662 | 663 | return !isInline ? ( 664 | 670 | {code} 671 | 672 | ) : ( 673 | {code} 674 | ); 675 | }; 676 | `.trim()} 677 | 678 | 679 | Passed to `react-markdown` as a `code` component in memo-ized chat messages: 680 | 681 | 682 | {` 683 | const RenderedMessage = React.memo(({ message }: { message: Message }) => ( 684 |
685 | 686 | {message.content} 687 | 688 |
689 | )); 690 | 691 | export const ChatMessages = ({ messages }: { messages: Message[] }) => { 692 | return ( 693 |
694 | {messages.map((message) => ( 695 | 696 | ))} 697 |
698 | ); 699 | }; 700 | `.trim()} 701 |
702 | -------------------------------------------------------------------------------- /playground/src/assets/bosque.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Bosque", 3 | "patterns": [ 4 | { 5 | "include": "#comments" 6 | }, 7 | { 8 | "include": "#string-double" 9 | }, 10 | { 11 | "include": "#string-single" 12 | }, 13 | { 14 | "include": "#constants" 15 | }, 16 | { 17 | "include": "#keywords" 18 | }, 19 | { 20 | "include": "#types" 21 | }, 22 | { 23 | "include": "#variables" 24 | } 25 | ], 26 | "repository": { 27 | "comments": { 28 | "patterns": [ 29 | { 30 | "captures": { 31 | "1": { 32 | "name": "punctuation.definition.comment.bosque" 33 | } 34 | }, 35 | "begin": "//", 36 | "end": "$", 37 | "name": "comment.line.double-dash.bosque" 38 | }, 39 | { 40 | "captures": { 41 | "1": { 42 | "name": "punctuation.definition.comment.bosque" 43 | } 44 | }, 45 | "begin": "/\\*", 46 | "end": "\\*/", 47 | "name": "comment.multiline.double-dash.bosque" 48 | } 49 | ] 50 | }, 51 | "string-double": { 52 | "name": "string.quoted.double.bosque", 53 | "begin": "\"", 54 | "beginCaptures": { 55 | "0": { 56 | "name": "punctuation.definition.string.begin.bosque" 57 | } 58 | }, 59 | "end": "\"", 60 | "endCaptures": { 61 | "0": { 62 | "name": "punctuation.definition.string.end.bosque" 63 | } 64 | }, 65 | "patterns": [ 66 | { 67 | "name": "constant.character.escape.bosque", 68 | "match": "\\\\." 69 | } 70 | ] 71 | }, 72 | "string-single": { 73 | "name": "string.quoted.single.bosque", 74 | "begin": "'", 75 | "beginCaptures": { 76 | "0": { 77 | "name": "punctuation.definition.string.begin.bosque" 78 | } 79 | }, 80 | "end": "'", 81 | "endCaptures": { 82 | "0": { 83 | "name": "punctuation.definition.string.end.bosque" 84 | } 85 | }, 86 | "patterns": [ 87 | { 88 | "name": "constant.character.escape.bosque", 89 | "match": "\\\\." 90 | } 91 | ] 92 | }, 93 | "keywords": { 94 | "patterns": [ 95 | { 96 | "name": "keyword.control.bosque", 97 | "match": "\\b(#if|#else|#endif|_debug|abort|assert|elif|else|ensures|if|invariant|match|release|return|requires|spec|doc|switch|debug|test|validate|yield)\\b" 98 | }, 99 | { 100 | "name": "keyword.bosque", 101 | "match": "\\b(#if|#else|#endif|recursive?|recursive|_debug|abort|assert|astype|concept|const|elif|else|enum|entity|ensures|err|field|fn|pred|function|if|import|invariant|istype|let|match|method|namespace|of|ok|operator|provides|ref|out|out?|release|return|requires|something|spec|doc|switch|debug|test|type|typedef|typedecl|datatype|using|validate|var|when|yield)\\b" 102 | } 103 | ] 104 | }, 105 | "constants": { 106 | "patterns": [ 107 | { 108 | "name": "constant.numeric.bosque", 109 | "match": "\\b((0|[1-9][0-9]*)_([A-Z][_a-zA-Z0-9]+))|(0|[1-9][0-9]*)/(0|[1-9][0-9]*)(_([A-Z][_a-zA-Z0-9]+))|([0-9]+(\\.[0-9]+)?|\\.[0-9]+)([eE][-+]?[0-9]+)?(_([A-Z][_a-zA-Z0-9]+))\\b" 110 | }, 111 | { 112 | "name": "constant.numeric.bosque", 113 | "match": "\\b((0|[1-9][0-9]*)(n|i|N|I))|((0|[1-9][0-9]*)/(0|[1-9][0-9]*)R)|(([0-9]+(\\.[0-9]+)?|\\.[0-9]+)([eE][-+]?[0-9]+)?(f|d))\\b" 114 | }, 115 | { 116 | "name": "constant.numeric.bosque", 117 | "match": "\\b(0|[1-9][0-9]*)|((0|[1-9][0-9]*)/(0|[1-9][0-9]*))|(([0-9]+(\\.[0-9]+)?|\\.[0-9]+)([eE][-+]?[0-9]+)?)\\b" 118 | }, 119 | { 120 | "name": "constant.language", 121 | "match": "\\b(none|nothing|true|false)\\b" 122 | } 123 | ] 124 | }, 125 | "types": { 126 | "patterns": [ 127 | { 128 | "name": "entity.name.type.bosque", 129 | "match": "\\b((([A-Z][_a-zA-Z0-9]+)::)*([A-Z][_a-zA-Z0-9]+))\\b" 130 | }, 131 | { 132 | "name": "entity.name.type.bosque", 133 | "match": "\\b[A-Z]\\b" 134 | } 135 | ] 136 | }, 137 | "variables": { 138 | "patterns": [ 139 | { 140 | "name": "variable.name", 141 | "match": "\\b(%([_a-z]|([_a-z][_a-zA-Z0-9]+))%)\\b" 142 | }, 143 | { 144 | "name": "variable.name", 145 | "match": "\\b([$]?([_a-z]|([_a-z][_a-zA-Z0-9]+)))\\b" 146 | } 147 | ] 148 | } 149 | }, 150 | "scopeName": "source.bsq" 151 | } 152 | -------------------------------------------------------------------------------- /playground/src/assets/mcfunction.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Syntax Mcfunction", 3 | "scopeName": "source.mcfunction", 4 | "uuid": "8918dadd-8ebe-42d9-b1e9-0489ab228d9d", 5 | "fileTypes": [ 6 | "mcfunction", 7 | "bolt" 8 | ], 9 | "patterns": [ 10 | { 11 | "include": "#root" 12 | } 13 | ], 14 | "repository": { 15 | "root": { 16 | "patterns": [ 17 | { 18 | "include": "#literals" 19 | }, 20 | { 21 | "include": "#comments" 22 | }, 23 | { 24 | "include": "#say" 25 | }, 26 | { 27 | "include": "#names" 28 | }, 29 | { 30 | "include": "#comments_inline" 31 | }, 32 | { 33 | "include": "#subcommands" 34 | }, 35 | { 36 | "include": "#property" 37 | }, 38 | { 39 | "include": "#operators" 40 | }, 41 | { 42 | "include": "#selectors" 43 | } 44 | ] 45 | }, 46 | "comments": { 47 | "patterns": [ 48 | { 49 | "applyEndPatternLast": 1, 50 | "begin": "^\\s*(#[>!#])(.+)$", 51 | "beginCaptures": { 52 | "1": { 53 | "name": "comment.block.mcfunction" 54 | }, 55 | "2": { 56 | "name": "markup.bold.mcfunction" 57 | } 58 | }, 59 | "captures": { 60 | "0": { 61 | "name": "comment.block.mcfunction" 62 | } 63 | }, 64 | "end": "^(?!#)", 65 | "name": "meta.comments", 66 | "patterns": [ 67 | { 68 | "include": "#comments_block" 69 | } 70 | ] 71 | }, 72 | { 73 | "captures": { 74 | "0": { 75 | "name": "comment.line.mcfunction" 76 | } 77 | }, 78 | "match": "^\\s*#.*$", 79 | "name": "meta.comments" 80 | } 81 | ] 82 | }, 83 | "comments_inline": { 84 | "patterns": [ 85 | { 86 | "captures": { 87 | "0": { 88 | "name": "comment.line.mcfunction" 89 | } 90 | }, 91 | "match": "#.*$", 92 | "name": "meta.comments" 93 | } 94 | ] 95 | }, 96 | "comments_block": { 97 | "patterns": [ 98 | { 99 | "applyEndPatternLast": 1, 100 | "begin": "^\\s*#[>!]", 101 | "captures": { 102 | "0": { 103 | "name": "comment.block.mcfunction" 104 | } 105 | }, 106 | "end": "$", 107 | "name": "meta.comments_block", 108 | "patterns": [ 109 | { 110 | "include": "#comments_block_emphasized" 111 | } 112 | ] 113 | }, 114 | { 115 | "applyEndPatternLast": 1, 116 | "begin": "^\\s*#", 117 | "captures": { 118 | "0": { 119 | "name": "comment.block.mcfunction" 120 | } 121 | }, 122 | "end": "$", 123 | "name": "meta.comments_block", 124 | "patterns": [ 125 | { 126 | "include": "#comments_block_normal" 127 | } 128 | ] 129 | } 130 | ] 131 | }, 132 | "comments_block_emphasized": { 133 | "patterns": [ 134 | { 135 | "include": "#comments_block_special" 136 | }, 137 | { 138 | "captures": { 139 | "0": { 140 | "name": "markup.bold.mcfunction" 141 | } 142 | }, 143 | "match": "\\S+", 144 | "name": "meta.comments_block_emphasized" 145 | } 146 | ] 147 | }, 148 | "comments_block_normal": { 149 | "patterns": [ 150 | { 151 | "include": "#comments_block_special" 152 | }, 153 | { 154 | "captures": { 155 | "0": { 156 | "name": "comment.block.mcfunction" 157 | } 158 | }, 159 | "match": "\\S+", 160 | "name": "meta.comments_block_normal" 161 | }, 162 | { 163 | "include": "#whitespace" 164 | } 165 | ] 166 | }, 167 | "comments_block_special": { 168 | "patterns": [ 169 | { 170 | "captures": { 171 | "0": { 172 | "name": "markup.heading.mcfunction" 173 | } 174 | }, 175 | "match": "@\\S+", 176 | "name": "meta.comments_block_special" 177 | }, 178 | { 179 | "include": "#resource-name" 180 | }, 181 | { 182 | "captures": { 183 | "0": { 184 | "name": "variable.other.mcfunction" 185 | } 186 | }, 187 | "match": "[#%$][A-Za-z0-9_.#%$]+", 188 | "name": "meta.comments_block_special" 189 | } 190 | ] 191 | }, 192 | "literals": { 193 | "patterns": [ 194 | { 195 | "captures": { 196 | "0": { 197 | "name": "constant.numeric.boolean.mcfunction" 198 | } 199 | }, 200 | "match": "\\b(true|false|True|False)\\b", 201 | "name": "meta.literals" 202 | }, 203 | { 204 | "captures": { 205 | "0": { 206 | "name": "variable.uuid.mcfunction" 207 | } 208 | }, 209 | "match": "\\b[0-9a-fA-F]+(?:-[0-9a-fA-F]+){4}\\b", 210 | "name": "meta.names" 211 | }, 212 | { 213 | "captures": { 214 | "0": { 215 | "name": "constant.numeric.float.mcfunction" 216 | } 217 | }, 218 | "match": "[+-]?\\d*\\.?\\d+([eE]?[+-]?\\d+)?[df]?\\b", 219 | "name": "meta.literals" 220 | }, 221 | { 222 | "captures": { 223 | "0": { 224 | "name": "constant.numeric.integer.mcfunction" 225 | } 226 | }, 227 | "match": "[+-]?\\d+(b|B|L|l|s|S)?\\b", 228 | "name": "meta.literals" 229 | }, 230 | { 231 | "captures": { 232 | "0": { 233 | "name": "variable.other.mcfunction" 234 | } 235 | }, 236 | "match": "\\.\\.", 237 | "name": "meta.ellipse.literals" 238 | }, 239 | { 240 | "applyEndPatternLast": 1, 241 | "begin": "\"", 242 | "beginCaptures": { 243 | "0": { 244 | "name": "punctuation.definition.string.begin.mcfunction" 245 | } 246 | }, 247 | "end": "\"", 248 | "endCaptures": { 249 | "0": { 250 | "name": "punctuation.definition.string.end.mcfunction" 251 | } 252 | }, 253 | "name": "string.quoted.double.mcfunction", 254 | "patterns": [ 255 | { 256 | "include": "#literals_string-double" 257 | } 258 | ] 259 | }, 260 | { 261 | "applyEndPatternLast": 1, 262 | "begin": "'", 263 | "beginCaptures": { 264 | "0": { 265 | "name": "punctuation.definition.string.begin.mcfunction" 266 | } 267 | }, 268 | "end": "'", 269 | "endCaptures": { 270 | "0": { 271 | "name": "punctuation.definition.string.begin.mcfunction" 272 | } 273 | }, 274 | "name": "string.quoted.single.mcfunction", 275 | "patterns": [ 276 | { 277 | "include": "#literals_string-single" 278 | } 279 | ] 280 | } 281 | ] 282 | }, 283 | "subcommands": { 284 | "patterns": [ 285 | { 286 | "captures": { 287 | "0": { 288 | "name": "entity.name.class.mcfunction" 289 | } 290 | }, 291 | "match": "[a-z_]+", 292 | "name": "meta.literals" 293 | } 294 | ] 295 | }, 296 | "literals_string-double": { 297 | "patterns": [ 298 | { 299 | "captures": { 300 | "0": { 301 | "name": "constant.character.escape.mcfunction" 302 | } 303 | }, 304 | "match": "\\\\.", 305 | "name": "meta.literals_string-double" 306 | }, 307 | { 308 | "captures": { 309 | "0": { 310 | "name": "constant.character.escape.mcfunction" 311 | } 312 | }, 313 | "match": "\\\\", 314 | "name": "meta.literals_string-double" 315 | }, 316 | { 317 | "include": "#macro-name" 318 | }, 319 | { 320 | "captures": { 321 | "0": { 322 | "name": "string.quoted.double.mcfunction" 323 | } 324 | }, 325 | "match": "[^\\\\\"]", 326 | "name": "meta.literals_string-double" 327 | } 328 | ] 329 | }, 330 | "literals_string-single": { 331 | "patterns": [ 332 | { 333 | "captures": { 334 | "0": { 335 | "name": "constant.character.escape.mcfunction" 336 | } 337 | }, 338 | "match": "\\\\.", 339 | "name": "meta.literals_string-single" 340 | }, 341 | { 342 | "captures": { 343 | "0": { 344 | "name": "constant.character.escape.mcfunction" 345 | } 346 | }, 347 | "match": "\\\\", 348 | "name": "meta.literals_string-double" 349 | }, 350 | { 351 | "include": "#macro-name" 352 | }, 353 | { 354 | "captures": { 355 | "0": { 356 | "name": "string.quoted.single.mcfunction" 357 | } 358 | }, 359 | "match": "[^\\\\']", 360 | "name": "meta.literals_string-single" 361 | } 362 | ] 363 | }, 364 | "say": { 365 | "patterns": [ 366 | { 367 | "begin": "^(\\s*)(say)", 368 | "beginCaptures": { 369 | "1": { 370 | "name": "whitespace.mcfunction" 371 | }, 372 | "2": { 373 | "name": "keyword.control.flow.mcfunction" 374 | } 375 | }, 376 | "end": "\\n", 377 | "name": "meta.say.mcfunction", 378 | "patterns": [ 379 | { 380 | "captures": { 381 | "0": { 382 | "name": "constant.character.escape.mcfunction" 383 | } 384 | }, 385 | "match": "\\\\\\s*\\n", 386 | "meta": "meta.say.backslash.mcfunction" 387 | }, 388 | { 389 | "include": "#literals_string-double" 390 | }, 391 | { 392 | "include": "#literals_string-single" 393 | } 394 | ] 395 | }, 396 | { 397 | "begin": "(run)(\\s+)(say)", 398 | "beginCaptures": { 399 | "1": { 400 | "name": "entity.name.mcfunction" 401 | }, 402 | "2": { 403 | "name": "whitespace.mcfunction" 404 | }, 405 | "3": { 406 | "name": "keyword.control.flow.mcfunction" 407 | } 408 | }, 409 | "end": "\\n", 410 | "name": "meta.say.mcfunction", 411 | "patterns": [ 412 | { 413 | "captures": { 414 | "0": { 415 | "name": "constant.character.escape.mcfunction" 416 | } 417 | }, 418 | "match": "\\\\\\s*\\n", 419 | "meta": "meta.say.backslash.mcfunction" 420 | }, 421 | { 422 | "include": "#literals_string-double" 423 | }, 424 | { 425 | "include": "#literals_string-single" 426 | } 427 | ] 428 | } 429 | ] 430 | }, 431 | "names": { 432 | "patterns": [ 433 | { 434 | "captures": { 435 | "1": { 436 | "name": "whitespace.mcfunction" 437 | }, 438 | "2": { 439 | "name": "keyword.control.flow.mcfunction" 440 | } 441 | }, 442 | "match": "^(\\s*)([a-z_]+)(?=\\s)", 443 | "name": "meta.names" 444 | }, 445 | { 446 | "captures": { 447 | "1": { 448 | "name": "whitespace.mcfunction" 449 | }, 450 | "2": { 451 | "name": "markup.italic.mcfunction" 452 | }, 453 | "3": { 454 | "name": "whitespace.mcfunction" 455 | }, 456 | "4": { 457 | "name": "keyword.control.flow.mcfunction" 458 | } 459 | }, 460 | "match": "^(\\s*)(\\$)( ?)([a-z_]*)", 461 | "name": "meta.names" 462 | }, 463 | { 464 | "captures": { 465 | "1": { 466 | "name": "entity.name.mcfunction" 467 | }, 468 | "2": { 469 | "name": "whitespace.mcfunction" 470 | }, 471 | "3": { 472 | "name": "keyword.control.flow.mcfunction" 473 | } 474 | }, 475 | "match": "(run)(\\s+)([a-z_]+)", 476 | "name": "meta.names" 477 | }, 478 | { 479 | "include": "#resource-name" 480 | }, 481 | { 482 | "captures": { 483 | "0": { 484 | "name": "entity.name.mcfunction" 485 | } 486 | }, 487 | "match": "[A-Za-z]+(?=\\W)", 488 | "name": "meta.names" 489 | }, 490 | { 491 | "captures": { 492 | "0": { 493 | "name": "string.unquoted.mcfunction" 494 | } 495 | }, 496 | "match": "[A-Za-z_][A-Za-z0-9_.#%$]*", 497 | "name": "meta.names" 498 | }, 499 | { 500 | "include": "#macro-name" 501 | }, 502 | { 503 | "captures": { 504 | "0": { 505 | "name": "variable.other.mcfunction" 506 | } 507 | }, 508 | "match": "([#%$]|((?<=\\s)\\.))[A-Za-z0-9_.#%$\\-]+", 509 | "name": "meta.names" 510 | } 511 | ] 512 | }, 513 | "macro-name": { 514 | "patterns": [ 515 | { 516 | "captures": { 517 | "1": { 518 | "name": "punctuation.definition.template-expression.begin.mcfunction" 519 | }, 520 | "2": { 521 | "name": "variable.other.mcfunction" 522 | }, 523 | "3": { 524 | "name": "punctuation.definition.template-expression.end.mcfunction" 525 | } 526 | }, 527 | "match": "(\\$\\()([A-Za-z0-9_]*)(\\))", 528 | "name": "meta.macro-name" 529 | } 530 | ] 531 | }, 532 | "operators": { 533 | "patterns": [ 534 | { 535 | "captures": { 536 | "0": { 537 | "name": "constant.numeric.mcfunction" 538 | } 539 | }, 540 | "match": "[~^]", 541 | "name": "meta.operators" 542 | }, 543 | { 544 | "captures": { 545 | "0": { 546 | "name": "keyword.operator.mcfunction" 547 | } 548 | }, 549 | "match": "[\\-%?!+*<>\\\\/|&=.:,;]", 550 | "name": "meta.operators" 551 | } 552 | ] 553 | }, 554 | "property": { 555 | "patterns": [ 556 | { 557 | "applyEndPatternLast": 1, 558 | "begin": "\\{", 559 | "captures": { 560 | "0": { 561 | "name": "punctuation.mcfunction" 562 | } 563 | }, 564 | "end": "\\}", 565 | "name": "meta.property.curly", 566 | "patterns": [ 567 | { 568 | "include": "#resource-name" 569 | }, 570 | { 571 | "include": "#literals" 572 | }, 573 | { 574 | "include": "#property_key" 575 | }, 576 | { 577 | "include": "#operators" 578 | }, 579 | { 580 | "include": "#property_value" 581 | }, 582 | { 583 | "include": "$self" 584 | } 585 | ] 586 | }, 587 | { 588 | "applyEndPatternLast": 1, 589 | "begin": "\\[", 590 | "captures": { 591 | "0": { 592 | "name": "variable.other.mcfunction" 593 | } 594 | }, 595 | "end": "\\]", 596 | "name": "meta.property.square", 597 | "patterns": [ 598 | { 599 | "include": "#resource-name" 600 | }, 601 | { 602 | "include": "#literals" 603 | }, 604 | { 605 | "include": "#property_key" 606 | }, 607 | { 608 | "include": "#operators" 609 | }, 610 | { 611 | "include": "#property_value" 612 | }, 613 | { 614 | "include": "$self" 615 | } 616 | ] 617 | }, 618 | { 619 | "applyEndPatternLast": 1, 620 | "begin": "\\(", 621 | "captures": { 622 | "0": { 623 | "name": "punctuation.mcfunction" 624 | } 625 | }, 626 | "end": "\\)", 627 | "name": "meta.property.paren", 628 | "patterns": [ 629 | { 630 | "include": "#resource-name" 631 | }, 632 | { 633 | "include": "#literals" 634 | }, 635 | { 636 | "include": "#property_key" 637 | }, 638 | { 639 | "include": "#operators" 640 | }, 641 | { 642 | "include": "#property_value" 643 | }, 644 | { 645 | "include": "$self" 646 | } 647 | ] 648 | } 649 | ] 650 | }, 651 | "property_key": { 652 | "patterns": [ 653 | { 654 | "captures": { 655 | "0": { 656 | "name": "variable.other.mcfunction" 657 | } 658 | }, 659 | "match": "#?[a-z_][a-z_\\.\\-]*\\:[a-z0-9_\\.\\-/]+(?=\\s*\\=:)", 660 | "name": "meta.property_key" 661 | }, 662 | { 663 | "captures": { 664 | "0": { 665 | "name": "variable.other.mcfunction" 666 | } 667 | }, 668 | "match": "#?[a-z_][a-z0-9_\\.\\-/]+", 669 | "name": "meta.property_key" 670 | }, 671 | { 672 | "captures": { 673 | "0": { 674 | "name": "variable.other.mcfunction" 675 | } 676 | }, 677 | "match": "[A-Za-z_]+[A-Za-z_\\-\\+]*", 678 | "name": "meta.property_key" 679 | } 680 | ] 681 | }, 682 | "property_value": { 683 | "patterns": [ 684 | { 685 | "captures": { 686 | "0": { 687 | "name": "string.unquoted.mcfunction" 688 | } 689 | }, 690 | "match": "#?[a-z_][a-z_\\.\\-]*\\:[a-z0-9_\\.\\-/]+", 691 | "name": "meta.property_value" 692 | }, 693 | { 694 | "captures": { 695 | "0": { 696 | "name": "string.unquoted.mcfunction" 697 | } 698 | }, 699 | "match": "#?[a-z_][a-z0-9_\\.\\-/]+", 700 | "name": "meta.property_value" 701 | } 702 | ] 703 | }, 704 | "resource-name": { 705 | "patterns": [ 706 | { 707 | "captures": { 708 | "0": { 709 | "name": "entity.name.function.mcfunction" 710 | } 711 | }, 712 | "match": "#?[a-z_][a-z0-9_.-]*:[a-z0-9_./-]+", 713 | "name": "meta.resource-name" 714 | }, 715 | { 716 | "captures": { 717 | "0": { 718 | "name": "entity.name.function.mcfunction" 719 | } 720 | }, 721 | "match": "#?[a-z0-9_\\.\\-]+\\/[a-z0-9_\\.\\-\\/]+", 722 | "name": "meta.resource-name" 723 | } 724 | ] 725 | }, 726 | "selectors": { 727 | "patterns": [ 728 | { 729 | "captures": { 730 | "0": { 731 | "name": "support.class.mcfunction" 732 | } 733 | }, 734 | "match": "@[a-z]+", 735 | "name": "meta.selectors" 736 | } 737 | ] 738 | } 739 | } 740 | } 741 | -------------------------------------------------------------------------------- /playground/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/assets/shikiLogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /playground/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin '@tailwindcss/typography'; 3 | 4 | @custom-variant dark (&:where(.dark, .dark *)); 5 | 6 | :root { 7 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 8 | line-height: 1.5; 9 | font-weight: 400; 10 | 11 | color-scheme: light dark; 12 | color: hsla(238, 100%, 95%, 1); 13 | background-color: hsl(233, 18%, 8%); 14 | 15 | font-synthesis: none; 16 | text-rendering: optimizeLegibility; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | 20 | overflow-x: hidden; 21 | } 22 | 23 | a { 24 | font-weight: 500; 25 | color: #646cff; 26 | text-decoration: inherit; 27 | } 28 | a:hover { 29 | color: #535bf2; 30 | } 31 | 32 | body { 33 | margin: 0; 34 | display: flex; 35 | place-items: center; 36 | min-width: 320px; 37 | min-height: 100vh; 38 | } 39 | 40 | h1 { 41 | font-size: 3.2em; 42 | line-height: 1.1; 43 | } 44 | 45 | button { 46 | border-radius: 8px; 47 | border: 1px solid transparent; 48 | padding: 0.6em 1.2em; 49 | font-size: 1em; 50 | font-weight: 500; 51 | font-family: inherit; 52 | background-color: #1a1a1a; 53 | cursor: pointer; 54 | transition: border-color 0.25s; 55 | } 56 | button:hover { 57 | border-color: #646cff; 58 | } 59 | button:focus, 60 | button:focus-visible { 61 | outline: 4px auto -webkit-focus-ring-color; 62 | } 63 | 64 | /* @media (prefers-color-scheme: light) { */ 65 | /* :root { */ 66 | /* color: #213547; */ 67 | /* background-color: #ffffff; */ 68 | /* } */ 69 | /* a:hover { */ 70 | /* color: #747bff; */ 71 | /* } */ 72 | /* button { */ 73 | /* background-color: #f9f9f9; */ 74 | /* } */ 75 | /* } */ 76 | -------------------------------------------------------------------------------- /playground/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App.tsx'; 4 | import './index.css'; 5 | 6 | ReactDOM.createRoot( 7 | document.getElementById('root') ?? document.body 8 | ).render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /playground/src/mdx.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.mdx' { 2 | let MDXComponent: (props) => JSX.Element; 3 | export default MDXComponent; 4 | } 5 | -------------------------------------------------------------------------------- /playground/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /playground/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /playground/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | // https://vitejs.dev/config/ 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import mdx from '@mdx-js/rollup'; 5 | import tailwind from '@tailwindcss/vite'; 6 | 7 | export default defineConfig({ 8 | plugins: [react(), mdx(), tailwind()], 9 | }); 10 | 11 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - package 3 | - playground -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":dependencyDashboard", 5 | ":ignoreModulesAndTests", 6 | ":rebaseStalePrs", 7 | ":semanticCommitType(chore)", 8 | "group:definitelyTyped", 9 | "group:monorepos", 10 | "group:recommended", 11 | "schedule:monthly", 12 | "replacements:all", 13 | "workarounds:all" 14 | ], 15 | "prHourlyLimit": 10, 16 | "rangeStrategy": "bump", 17 | "labels": ["dependencies"], 18 | "commitMessageTopic": "{{depName}}", 19 | "commitBodyTable": true, 20 | "timezone": "America/New_York", 21 | "packageRules": [ 22 | { 23 | "matchDepTypes": ["peerDependencies"], 24 | "enabled": false 25 | }, 26 | { 27 | "groupName": "playground dependencies", 28 | "matchFileNames": ["playground/**"], 29 | "matchUpdateTypes": ["minor", "patch", "major"] 30 | }, 31 | { 32 | "groupName": "package dev-dependencies", 33 | "semanticCommitScope": "dev-deps", 34 | "matchDepTypes": ["devDependencies"], 35 | "matchFileNames": ["package/**"] 36 | }, 37 | { 38 | "groupName": "package core dependencies", 39 | "matchDepTypes": ["dependencies"], 40 | "matchFileNames": ["package/**"] 41 | }, 42 | { 43 | "groupName": "root dev-dependencies", 44 | "semanticCommitScope": "dev-deps", 45 | "matchDepTypes": ["devDependencies"], 46 | "matchFileNames": ["package.json"] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /scripts/release.mjs: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process'; 2 | import { resolve } from 'node:path'; 3 | 4 | /** 5 | * 6 | * @param {string} command 7 | * @param {...Array} args 8 | * 9 | * @returns {Promise} 10 | */ 11 | const run = async (command, ...args) => { 12 | const cwd = resolve(); 13 | return new Promise((resolve) => { 14 | const cmd = spawn(command, args, { 15 | stdio: ['inherit', 'pipe', 'pipe'], // Inherit stdin, pipe stdout, pipe stderr 16 | shell: true, 17 | cwd, 18 | }); 19 | 20 | let output = ''; 21 | 22 | cmd.stdout.on('data', (data) => { 23 | process.stdout.write(data.toString()); 24 | output += data.toString(); 25 | }); 26 | 27 | cmd.stderr.on('data', (data) => { 28 | process.stderr.write(data.toString()); 29 | }); 30 | 31 | cmd.on('close', () => { 32 | resolve(output); 33 | }); 34 | }); 35 | }; 36 | 37 | const main = async () => { 38 | await run('pnpm changeset version'); 39 | await run('git add .'); 40 | await run('git commit -m "chore: update version"'); 41 | await run('git push'); 42 | await run('pnpm --filter react-shiki build'); 43 | await run('pnpm changeset publish'); 44 | await run('git push --follow-tags'); 45 | const tag = (await run('git describe --abbrev=0')).replace('\n', ''); 46 | await run( 47 | `gh release create ${tag} --title ${tag} --notes "Please refer to [CHANGELOG.md](https://github.com/AVGVSTVS96/react-shiki/blob/main/package/CHANGELOG.md) for details."` 48 | ); 49 | }; 50 | 51 | main(); 52 | --------------------------------------------------------------------------------