rehype plugin to highlight codeblocks with Starry Night
npm install @microflash/rehype-starry-night


rehype plugin to highlight code with starry-night
- What’s this?
- When should I use this?
- Install
- Use
- API
- Plugin API
- Theming
- Supporting Light and Dark themes
- Examples
- Example: using aliases
- Example: using all starry-night grammars
- Example: using custom starry-night grammar
- Example: skip highlighting a specific language
- Example: show codeblock title
- Example: show codeblock language
- Example: show a prompt before a line
- Example: show highlighted, inserted and deleted lines
- Example: show line numbers
- Example: customize with your own plugin
- Example: customize classname prefix
- Related
- License
This package is a unified (rehype) plugin to highlight code with starry-night in a markdown document. It mimics GitHub's syntax highlighting.
This project is useful if you want to use the syntax highlighting powered by VS Code's syntax highlighter engine, and themes similar to GitHub. It is also useful if you want to build your own syntax highlighting themes based on CSS custom properties. You can enable additional features through plugins using Plugin API based on your specific need; they are all opt-in.
This package is ESM only.
In Node.js (version 16.0+), install with npm:
``sh`
npm install @microflash/rehype-starry-night
In Deno, with esm.sh:
`js`
import rehypeStarryNight from "https://esm.sh/@microflash/rehype-starry-night";
In browsers, with esm.sh:
`html`
Say you have the following file index.md:
`css`
html {
box-sizing: border-box;
text-size-adjust: 100%;
/ allow percentage based heights for the children /
height: 100%;
}
And our module index.js looks as follows:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight)
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running that with node index.js yields:
`html`html {
box-sizing: border-box;
text-size-adjust: 100%;
/ allow percentage based heights for the children /
height: 100%;
}
!Syntax highlighting with Rehype Starry Night
The default export is rehypeStarryNight. The following options are available. All of them are optional.
- namespace (type: string, default: hl): class name of the highlighted codeblockgrammars
- (type: Array, default: common) - starry-night compatible grammar definitions. By default, common grammars provided by starry-night are used.aliases
- (type: Record, default: {}): aliases to force syntax highlighting. By default, unknown languages are not highlighted.plainText
- (type: Array, default: []): array of languages not to highlightplugins
- (type: Array, default: []) - array of plugins to customize the highlighted codeblock, using Plugin APIallowMissingScopes
- (type: boolean, default: false) - whether to warn for missing scope or not
You can customize the highlighted codeblock with a plugin. A plugin looks like this:
`js`
export const myPlugin = {
type: "header",
opts: meta => {
// parse codeblock metadata
return {
// plugin specific options
}
},
apply: (opts, node) => {
// do something with plugin specific options and codeblock node
}
};
- type (type: string, possible values: header, line or footer) - controls whether the customization applies to the header, line or footer of the codeblock elementopts
- (type: function, arguments: meta, return: Object) - optional option processing function. You can use this to read the metadata associated with a codeblock (parsed by fenceparser) and return plugin specific options derived from them. By default, the metadata contains the language associated with the codeblock.apply
- (type: function, arguments: opts and nodes) - required function that applies customizations on the codeblock nodes (an array of hast nodes) based on plugin options (supplied by opts function)
rehype-starry-night ships a few plugins out of box, with which you can
- add title to your codeblock
- show language of the codeblock
- show highlighted, inserted, and deleted lines
- show a prompt before a line to indicate a command line prompt
- mark lines as command output (so they are not copied alongwith command)
These plugins are opt-in and you'll have to manually import them.
`js
import {
titlePlugin,
languageIndicatorPlugin,
lineAnnotationPlugin
} from "@microflash/rehype-starry-night/plugins";
const processor = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
plugins: [
titlePlugin,
languageIndicatorPlugin,
lineAnnotationPlugin
]
})
.use(rehypeStringify, { allowDangerousHtml: true });
`
Plugins are applied in the order they are added in options. In the above case, titlePlugin will be applied first followed by languageIndicatorPlugin and lineAnnotationPlugin.
Import props.css and index.css files in your project, or use them as a base for your own custom theme. For different color schemes for syntax highlighting, check the available themes on starry-night repository.
Here's one way to support light and dark themes; the appropriate theme will get activated based on system preferences.
`css
:root {
/ light theme variables specific to rehype-starry-night plugin /
--hl-bg-color: hsl(220, 23%, 97%);
--hl-border-color: hsl(215, 15%, 85%);
--hl-outline-color: hsl(215, 15%, 70%, 0.5);
}
@media (prefers-color-scheme: dark) {
:root {
/ dark theme variables specific to rehype-starry-night plugin /
--hl-bg-color: hsl(216, 18%, 11%);
--hl-border-color: hsl(215, 11%, 22%);
--hl-outline-color: hsl(215, 11%, 37%, 0.5);
}
}
/ import a starry-night theme that supports both dark and light themes /
@import "https://raw.githubusercontent.com/wooorm/starry-night/main/style/both.css";
/ import CSS specific to rehype-starry-night plugin /
@import "https://raw.githubusercontent.com/Microflash/rehype-starry-night/main/src/index.css";
`
> [!WARNING]
> URL imports for external styles is not recommended. You should either self-host them or bundle them.
Say you have the following file index.md:
`xjm`
language: "en"
customization: false
features: [ "io", "graphics", "compute" ]
You can alias xjm to yml as follows with index.js:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
aliases: {
xjm: "yml"
}
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running this with node index.js yields:
`html`language: "en"
customization: false
features: [ "io", "graphics", "compute" ]
By default, this plugin uses the common grammars provided by starry-night. You can import all grammars to highlight the code in languages not included in the common grammars.
Say, you have the following codeblock in index.md:
`ballerina
import ballerina/io;
public function main() {
io:println("Hello, World!");
}
`
Since, ballerina is not included in the common grammars, you'll have to import all grammars from the starry-night as follows in index.js:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { all } from "@woorm/starry-night";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
grammars: all
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running this with node index.js yields:
` public function main() {html`import ballerina/io;
io:println("Hello, World!");
}
!Using all starry-night grammars
If you want to highlight a language for which starry-night does not provide a grammar, you can convert a Text Mate grammar to starry-night compatible format and use it alongside other grammar definitions.
Say, you have a custom grammar to highlight log files in text.log.js and you want to highlight the following file index.md:
`log`
2025-05-18T23:08:48.269 INFO 10683 --- [main] com.zaxxer.hikari.HikariDataSource : H2HikariPool - Starting...
2025-05-18T23:08:48.338 INFO 10683 --- [main] com.zaxxer.hikari.pool.HikariPool : H2HikariPool - Added connection conn0: url=jdbc:h2:mem:sa user=SA
2025-05-18T23:08:48.338 INFO 10683 --- [main] com.zaxxer.hikari.HikariDataSource : H2HikariPool - Start completed.
You can import text.log.js alongside common grammars as follows in index.js:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { common } from "@wooorm/starry-night";
import logGrammar from "./text.log.js";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
grammars: [
logGrammar,
...common
]
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running this with node index.js yields:
`html`2025-05-18T23:08:48.269 INFO 10683 --- [main] com.zaxxer.hikari.HikariDataSource : H2HikariPool - Starting...
2025-05-18T23:08:48.338 INFO 10683 --- [main] com.zaxxer.hikari.pool.HikariPool : H2HikariPool - Added connection conn0: url=jdbc:h2:mem:sa user=SA
2025-05-18T23:08:48.338 INFO 10683 --- [main] com.zaxxer.hikari.HikariDataSource : H2HikariPool - Start completed.
!Using custom starry-night grammar
In some cases, you may want to process codeblocks of a specific language differently. Say you have the following file index.md where you want to render the Mermaid diagram instead of syntax highlighting:
`mermaid`
flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
You can skip highlighting mermaid codeblock by listing it in plainText option as follows in the index.js:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
plainText: [
"mermaid"
]
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running this with node index.js yields:
`html`flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
> [!NOTE]
> Codeblocks with unknown languages (that is, languages for which there are no available starry-night grammars) or no language are always skipped by the plugin.
You can add a title to a codeblock, like in the following file index.md:
`zsh title="Switching off homebrew telemetry"`
# turns off homebrew telemetry
export HOMEBREW_NO_ANALYTICS=1
# turns off homebrew auto-update
export HOMEBREW_NO_AUTO_UPDATE=1
To show this title, import title plugin in index.js as follows:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { titlePlugin } from "@microflash/rehype-starry-night/plugins";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
plugins: [
titlePlugin
]
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running this with node index.js yields:
`html`
Switching off homebrew telemetry
# turns off homebrew telemetry
export HOMEBREW_NO_ANALYTICS=1
# turns off homebrew auto-update
export HOMEBREW_NO_AUTO_UPDATE=1
Say, you have the following file index.md:
`rust`
fn main() {
println!("Hello, world!");
}
To display the language associated with this codeblock, import languageIndicator plugin in the index.js as follows:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { languageIndicatorPlugin } from "@microflash/rehype-starry-night/plugins";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
plugins: [
languageIndicatorPlugin
]
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running this with node index.js yields:
`html`fn main() {
println!("Hello, world!");
}
!Codeblock with language indicator
Sometime you may want to display a command and its output. You can specify lines that are command-line instructions and lines that are output, as follows in index.md:
`sh prompt{1} output{2..4}`
eza --version
eza - A modern ls replacement
v0.23.4 [+git]
Import lineAnnotation plugin in the index.js as follows:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { lineAnnotationPlugin } from "@microflash/rehype-starry-night/plugins";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
plugins: [
lineAnnotationPlugin
]
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running this with node index.js yields:
`html`eza --version
eza - A modern ls replacement
v0.23.4 [+git]
> [!NOTE]
> The selection of prompt character should be disabled so that when people copy the command, the prompt is not copied. This behavior is implemented in index.css with user-select: none. Similarly, the output is also disabled for user selection because you usually want people to just copy the command and not the output.
You can highlight lines by specifying the line numbers (or even, range of line numbers) between curly braces in the codeblock metadata.
`sh {3,5..12} prompt{1}`
curl http://localhost:12434/engines/llama.cpp/v1/chat/completions \
--json '{
"model": "ai/smollm2",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "Write 500 words about the fall of Rome."
}
]
}'
Import lineAnnotation plugin (described in the previous example) and run node index.js which yields:
`html`curl http://localhost:12434/engines/llama.cpp/v1/chat/completions \
--json '{
"model": "ai/smollm2",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "Write 500 words about the fall of Rome."
}
]
}'
!Codeblock with highlighted lines
Similarly, you can render inserted and deleted lines using ins and del properties on the codeblock followed by a range of line numbers.
`js title="Pool options in Vitest 2.0" del{4..6} ins{7..9}`
export default defineConfig({
test: {
poolOptions: {
threads: {
singleThread: true,
},
forks: {
singleFork: true,
},
}
}
});
The above codeblock gets rendered as:
`html` export default defineConfig({
test: {
poolOptions: {
- threads: {
- singleThread: true,
- },
+ forks: {
+ singleFork: true,
+ },
}
}
});
!Codeblock with inserted and deleted lines
> [!NOTE]
> See the documentation of fenceparser to learn about the ways in which you can specify the line range.
When you import the lineAnnotation plugin (as seen in previous example), it adds data-line-number attribute to every line. It also attaches --hl-line-gutter CSS custom property on the code element. Using these two details, you can use the following CSS to show line numbers.
For single line codeblocks, the numbers won't show up.
!Single line codeblock without line number
Suppose you want to add a copy to clipboard button in the footer. You can do so by adding a custom footer plugin.
Say you have the following file index.md:
`html`
highlighted
You can pass a custom footer plugin as follows with index.js:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { languageIndicatorPlugin } from "@microflash/rehype-starry-night/plugins";
import { h } from "hastscript";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
plugins: [
languageIndicatorPlugin,
{
type: "footer",
apply: (opts, node) => {
if (opts.id) node.push(h(button.${opts.namespace}-copy, "Copy to clipboard"));
}
}
]
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running this with node index.js yields:
`html`<mark>highlighted</mark>
!Codeblock with custom footer plugin
Say you have the following file index.md:
`java`
IO.println("Hello, world!");
You can customize the classname prefix of codeblock element by setting the namespace option, as follows with index.js:
`js
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeStarryNight from "@microflash/rehype-starry-night";
import { readFileSync } from "node:fs";
main();
async function main() {
const markdown = readFileSync("./index.md", "utf8");
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStarryNight, {
namespace: "highlight"
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
console.log(String(file));
}
`
Running this with node index.js yields:
`html`IO.println("Hello, world!");
- rehype-starry-night — alternative plugin to apply syntax highlighting to code with starry-nightrehype-highlight
- — highlight code with highlight.js (through lowlight)rehype-prism-plus
- — highlight code with Prism (via refractor) with additional line highlighting and line numbers functionalities@shikijs/rehype` — highlight code with shiki
-