Skip to content

Comment notation

The standard way to activate Expressive Code features is with attributes on the fence line, such as {3} or ins={5}. The fence line is the first line of the code block, with the language. This means you need to be careful when you edit the code in a code block. If you add or remove any lines, the counts change and the attribute can point at the wrong code.

To avoid this, starlight-codeblocks also offers configuration via directives. A directive sits in a comment on or above the line that it marks. It moves with the code, so you don’t need to count line numbers.

Readers see

config.ts
export const config = {
host: 'localhost',
protocol: 'https',
port: 3000,
port: Number(process.env.PORT ?? 3000),
timeout: 5000,
};

You write

```ts title="config.ts"
export const config = {
// [!code highlight]
host: 'localhost',
protocol: 'https',
port: 3000, // [!code --]
port: Number(process.env.PORT ?? 3000), // [!code ++]
timeout: 5000,
};
```

Same example, built instead with attributes:

```ts title="config.ts" mark={2} del={4} ins={5}
export const config = {
host: 'localhost',
protocol: 'https',
port: 3000,
port: Number(process.env.PORT ?? 3000),
timeout: 5000,
};
```

Each directive changes its line. Each comment holds only a directive, so the plugin removes the whole comment. The copy button copies the code without the directives.

A directive is text in square brackets that starts with !. It is written in a comment, using the appropriate comment syntax of the language of that code block.

Directives turn on many different features. <name> is the name of a directive, such as focus or ++. They all use one of these forms:

Form Meaning
[!code <name>] Applies to the line it is on.
[!code <name>:N] Applies to its line and the next N-1 lines.
[!code <name> <name>] Applies each name to the line.
[!<name>] <text> Shows <text> after the code, for example as a note or a message. The text stops at the next directive or at the end of the comment.
[!code <name> <name>] <text> Applies each name to the line, and shows <text> for the first name that can show text.
[!<name> /<text>/] Points at the first match of the literal text on its target line.
[\!code <name>] Renders as the literal text [!code <name>].

For example, // [!code ++:3] marks its line and the next two lines as inserted. // [!code focus ++] Loads the plugin focuses its line and marks it as inserted, with “Loads the plugin” as the message of the inserted line.

Every directive can go at the end of the line it marks, or on its own line above it. The plugin removes each line that holds only directives, so it does not show on the page.

A directive can share a comment with an ordinary comment. The plugin removes only the directive, and the rest of the comment stays in the code:

Readers see

const host = 'localhost'
const port = 8080 // Default port

You write

```js
const host = 'localhost'
const port = 8080 // Default port [!code focus]
```

Text after a directive that takes text, such as [!code error] or [!callout], is the text of that directive. Put your own comment before the directive.

One code block often needs several features: a focus, a changed line with a message, and a note to explain it. The features work together in one block, and each one keeps its own effect.

One [!code …] directive can hold several names. Each name applies to the line. The text after the directive goes to the first name that takes text, here ++.

Readers see

astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import codeblocks from 'starlight-codeblocks';Import the plugin
export default defineConfig({
integrations: [
starlight({
title: 'My docs',
plugins: [codeblocks()],Load the plugin
}),
],
});

You write

```js title="astro.config.mjs"
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import codeblocks from 'starlight-codeblocks'; // [!code focus ++] Import the plugin
export default defineConfig({
integrations: [
starlight({
title: 'My docs',
plugins: [codeblocks()], // [!code focus ++] Load the plugin
}),
],
});
```

The two new lines are green, show their messages, and stay sharp. The other lines are blurred. To give two line states their own messages, use two lines.

One comment can hold several directives. Each directive takes the text up to the next directive.

Readers see

retry.py
import time
def fetch_with_retry(fetch, retries=3):
for attempt in range(retries):
try:
return fetch()
Warning: except TimeoutError: Other errors fail at once

Only a timeout is worth a retry.

time.sleep(2**attempt)Back off
raise TimeoutError("no response")
  1. Only a timeout is worth a retry.

You write

```py title="retry.py"
import time
def fetch_with_retry(fetch, retries=3): # [!mention retries]
for attempt in range(retries):
try:
return fetch()
except TimeoutError: # [!code warning] Other errors fail at once [!annotate] Only a timeout is worth a retry.
time.sleep(2**attempt) # [!code ++] Back off [!mention retries]
raise TimeoutError("no response")
```

The except line has a warning and an annotation. The sleep line is new, and it is part of the retry loop with the first line.

Attributes on the fence line and directives in comments add up. Here the fence line names a placeholder and hides the imports. The comments add a callout, a footnote and an error.

Readers see

client.py
import os
import requests
session = requests.Session()
Sent with every request
session.headers["Authorization"] = "Bearer YOUR_TOKEN"
session.timeout = 301
Error:session.verify = False Never turn off TLS checks
  1. 1.The service closes idle connections after 30 s.

You write

```py title="client.py" placeholder="YOUR_TOKEN" hidden={1-2}
import os
import requests
session = requests.Session()
# [!callout /headers/] Sent with every request
session.headers["Authorization"] = "Bearer YOUR_TOKEN"
# [!ref] The service closes idle connections after 30 s.
session.timeout = 30
session.verify = False # [!code error] Never turn off TLS checks
```

Readers can type their token into the field. The copy button copies their token, the hidden imports and none of the notes.

This block turns on 15 features at once. It has focus, every line state and a word-level diff. It has annotations, mentions, a callout, a footnote and a code link. On the fence line, it adds a placeholder, hidden lines, visible whitespace, line permalinks, an expandable block and colourised brackets. Its title gets a file icon.

Do not publish a block like this. It shows that each feature keeps its own effect.

The health route and the app are mentions.

Readers see

server.js
import 'dotenv/config';
import express from 'express';
const app = express();
const PORT = 3000;
const PORT = Number(process.env.PORT ?? 3000);Read the port from the environment
app.use(express.json());Parse JSON bodies

Before any route, or req.body is undefined.

Checked on every request
app.use((req, res, next) => (req.get('x-api-key') === 'YOUR_API_KEY' ? next() : res.sendStatus(401)));
app.get('/health', (req, res) => {1
Success: res.json({ ok: true, uptime: process.uptime() }); Always 200
});
const api = express.Router();
Note:api.get('/users/:id', async (req, res) => { Needs a database
Error: const user = await db.users.find(req.params.id); db is not defined
To do: res.json(user ?? {}); Return 404 when missing
Warning:}); No error handler
app.use('/api', api);

Every route in api starts with /api.

app.listen(PORT, () => console.log('Listening on', PORT));
  1. Before any route, or req.body is undefined.
  2. Every route in api starts with /api.
  1. 1.Load balancers call this route every 10 s.

You write

```js title="server.js" id="kitchen-sink" placeholder="YOUR_API_KEY" hidden={1-2} whitespace="all" expandable={12} brackets
import 'dotenv/config';
import express from 'express';
const app = express(); // [!mention app]
const PORT = 3000; // [!code --]
const PORT = Number(process.env.PORT ?? 3000); // [!code ++] Read the port from the environment
app.use(express.json()); // [!code ++] Parse JSON bodies [!annotate] Before any route, or `req.body` is `undefined`.
// [!callout /x-api-key/] Checked on every request
app.use((req, res, next) => (req.get('x-api-key') === 'YOUR_API_KEY' ? next() : res.sendStatus(401)));
// [!ref] Load balancers call this route every 10 s.
app.get('/health', (req, res) => { // [!mention health] [!code focus:3]
res.json({ ok: true, uptime: process.uptime() }); // [!code success] Always 200 [!mention health]
});
// [!link /express.Router/ https://expressjs.com/en/5x/api.html#router] Groups routes under one prefix.
const api = express.Router(); // [!mention app]
api.get('/users/:id', async (req, res) => { // [!code info] Needs a database
const user = await db.users.find(req.params.id); // [!code error] `db` is not defined
res.json(user ?? {}); // [!code todo] Return 404 when missing
}); // [!code warning] No error handler
app.use('/api', api); // [!code highlight] [!annotate] Every route in `api` starts with `/api`.
app.listen(PORT, () => console.log('Listening on', PORT)); // [!mention app]
```

The plugin reads the directives when the site builds. It needs no JavaScript in the browser.

  • Removing directives:
    • The plugin removes every directive from the rendered code and from the copied text.
    • If a comment holds only directives, the plugin removes the whole comment and the whitespace before it. Other text in the comment stays.
    • In JSX, TSX and MDX, the plugin also reads comments in braces, {/* [!code focus] */}, and removes the braces with the comment.
    • A backslash after the bracket, as in [\!code focus], turns a directive into plain text. The plugin removes the backslash.
  • Expressive Code markers:
    • [!code highlight], [!code ++] and [!code --] give the same result as the mark, ins and del attributes of Expressive Code. Text after them shows as a message, as for line states.
  • Text and copy:
    • Inline code, links and bold in the text of a directive render as HTML. Other Markdown stays as text.
    • The copy button leaves out messages, annotations, callouts and footnotes.

Line numbers in attributes count the lines that readers see. The plugin removes lines that hold only directives before it counts, so they do not change the numbers. This applies to the attributes of Expressive Code as well, such as {2} and ins={3}.

The build logs a warning with the file, the code block and the line when:

  • a directive has an unknown name, or the feature it belongs to is off;
  • the /<text>/ of a directive has no match on its target line;
  • a directive on its own line has no line below it;
  • a directive is not in a comment that the block reads, such as <!-- --> in an mdx block. A directive in a string does not give this warning.

The directive then has no effect. An unknown directive, or one outside a comment, stays in the code as written, so you can see it on the page.

Reads directives in code comments. Set it to false to turn the feature off. Every comment then renders as written.

Type
false | object
Default
On

Comment syntax for each language, added to the built-in map. An empty list removes a language.

Type
Record<string, string[]>
Default
{}

Each entry in notation.comments is a language and a list of comment syntaxes. A syntax with a space in it is a block comment: the opener, then the closer.

astro.config.mjs
codeblocks({
notation: {
comments: {
cypher: ['//'],
jinja: ['{# #}'],
},
},
});

An entry replaces the built-in syntax of that language. A language name or its alias works: an entry for powershell also applies to ```pwsh. The built-in map:

Comment syntaxLanguages
//, /* */c, c#, c++, cc, cjs, cpp, cs, csharp, cts, dart, go, groovy, h, hpp, java, javascript, js, json5, jsonc, kotlin, kt, kts, mjs, mts, nextflow, nf, php, rs, rust, scala, swift, ts, typescript
//, /* */, {/* */}jsx, tsx
#bash, console, docker, dockerfile, make, makefile, nix, perl, pl, powershell, ps, ps1, py, pycon, python, r, rb, ruby, sh, shell, shellscript, toml, yaml, yml, zsh
--haskell, hs, lua, sql
<!-- -->html, markdown, md, svg, xml
{/* */}mdx
<!-- -->, //, /* */astro, svelte, vue
/* */css
/* */, //less, sass, scss
%erl, erlang, latex, tex
;clj, clojure, ini, lisp

Python and shell blocks use # comments:

Readers see

parse.py
import json
data = json.loads(text)
print(data["name"])

You write

```py title="parse.py"
import json
data = json.loads(text) # [!code highlight]
print(data["name"])
```

HTML blocks use <!-- --> comments. :2 marks two lines with one directive:

Readers see

nav.html
<nav>
<a href="/">Home</a>
<a href="/docs/">Docs</a>
</nav>

You write

```html title="nav.html"
<nav>
<a href="/">Home</a> <!-- [!code ++:2] -->
<a href="/docs/">Docs</a>
</nav>
```

To show a directive as text, for example in a page about this plugin, add a backslash:

Readers see

server.js
const port = 8080; // [!code highlight]

You write

```js title="server.js"
const port = 8080; // [\!code highlight]
```
  • JSON has no comments, so a json block cannot use directives. Use jsonc, or use attributes.
  • The plugin skips a comment opener inside a string that opens and closes on the same line, such as "// [!code ++]". A string that continues on the next line, such as a template literal, is not skipped. Escape a directive in it with a backslash.
  • One comment on each line can hold directives. Text such as [!NOTE] outside a comment, as in a Markdown alert, stays in the code. The plugin still reads the comment after it.
  • Focus: use [!code focus] to blur the lines that do not matter.
  • Line states: use [!code error] and the other states to tint a line and add a message.