Skip to content
Living Life

Code blocks & syntax highlighting with Expressive Code

Frame titles, copy buttons, line markers, diffs, terminal frames, word wrap and collapsible sections — every Expressive Code feature this theme exposes.

Authoring2min read

Every fenced code block on this site is rendered at build time by Expressive Code, powered by Shiki. That means server-rendered, accessible, copy-pasteable HTML with no client-side highlighter — only a tiny script for the copy button.

The integration is configured in astro.config.mjs with two themes: github-light for chirpy-light, and github-dark-dimmed for chirpy-dark. Switching the site theme instantly flips the code palette too.

Three backticks plus a language identifier:

function greet(name: string) {
return `Hello, ${name}!`;
}

If you want to write raw HTML inside a markdown code block and have it rendered directly instead of highlighted as code, use the ashtml language identifier. A custom Sätteri plugin converts these blocks into raw HTML nodes at build time.

```ashtml
<div class="alert alert-success">
<span>This is raw HTML rendered beautifully!</span>
</div>
```

Renders as:

This is raw HTML rendered beautifully!

If you want daisyUI alert components directly from Markdown/MDX, use the alert language identifier. A dedicated Sätteri plugin converts each block into daisyUI-compatible alert markup.

```alert
type: success
style: soft
icon: lucide:check-circle
title: Purchase confirmed!
description: Your order has been placed successfully.
```

For the complete matrix of variants (type, style, direction, icon, title, description, class), see Alert plugin: showcase of all variants.

Add title="..." to label the block — it shows in a window-style title bar:

src/utils/greet.ts
function greet(name: string) {
return `Hello, ${name}!`;
}

Use frame="terminal" for shell snippets — Expressive Code switches to a terminal-style chrome:

Terminal
bun install
bun run dev

To force the code frame on something Expressive Code might auto-detect as a terminal:

echo "this is treated as a code file, not a terminal"

Highlight, mark, insert, or delete specific lines.

import { defineCollection, z } from 'astro:content';
export const collections = {
posts: defineCollection({ schema: z.object({ title: z.string() }) }),
};

import { defineCollection, z } from 'astro:content';
export const collections = {
posts: defineCollection({
schema: z.object({ title: z.string() }),
}),
};

const old = 'remove me';
const stale = 'and me too';
const keeper = 'survive';

Highlight every occurrence of a literal:

import { useTranslations } from '../i18n/utils';
const t = useTranslations(locale);
const heading = t('post.comments');

The diff language is supported natively. Lines starting with + and - are coloured automatically.

Refactor: hero image source
const heroSrc = typeof img === 'string' ? img : img?.src;
const heroSrc = heroImageSrc(post);
const showHero = shouldShowHero(post);

Long files? Collapse parts of the snippet so readers focus on the relevant bit. Use collapse={start-end}:

src/utils/posts.ts
import { getCollection, type CollectionEntry } from 'astro:content';
import { SITE, type Locale } from '../config';
// helper utilities live above
function detectLocale(id: string): Locale {
return id.startsWith('fr/') ? 'fr' : 'en';
}
export async function getPosts(locale: Locale) {
const all = await getCollection('posts');
return all.filter((p) => detectLocale(p.id) === locale);
}

Click the collapsed lines indicator to expand the hidden block.

Long lines are normally horizontal-scrolled. To wrap them instead, use the wrap modifier:

const veryLongValue =
'a string that would normally cause a horizontal scrollbar but is now soft-wrapped onto multiple lines for narrow viewports';

Add preserveIndent to keep wrapped lines aligned with the opening indentation, or set it once globally in the expressiveCode block of astro.config.mjs.

Every block has a copy-to-clipboard button in its top-right corner. The icon switches to a check mark when the copy succeeds, then resets after a couple of seconds. Translation strings are read from src/i18n/ui.ts (code.copy, code.copied) so the French build shows a French label.

Expressive Code does not show line numbers by default — Chirpy doesn’t either. If you want them, add the @expressive-code/plugin-line-numbers plugin to astro.config.mjs and enable it per-block with showLineNumbers.

A realistic block uses several modifiers at once:

src/utils/seo.ts
import type { CollectionEntry } from 'astro:content';
import type { Locale } from '../config';
export function buildSeo(entry: CollectionEntry<'posts'>, locale: Locale) {
const canonical = entry.data.canonicalURL ?? defaultCanonical(entry, locale);
const ogImage = entry.data.heroImage ?? ogDefaultImg.src;
const lang = entry.data.lang ?? locale;
return { canonical, ogImage, lang };
}

Open astro.config.mjs and edit the expressiveCode options to swap themes:

astro.config.mjs
expressiveCode({
themes: ['github-light', 'github-dark-dimmed'],
themeCssSelector: (theme) =>
theme.name === 'github-light' ? '[data-theme="chirpy-light"]' : '[data-theme="chirpy-dark"]',
styleOverrides: { borderRadius: '0.5rem' },
});

Pick any Shiki bundled theme — both themes must be listed for the dual-mode CSS to be emitted.

Comments

Comments need configuration

Giscus is enabled but not yet configured. Add the repository details below to start collecting comments.

  1. Visit giscus.app and select your public GitHub repository (Discussions must be enabled).
  2. Copy the generated data-repo-id, data-category and data-category-id values.
  3. Set the PUBLIC_GISCUS_ENABLED, PUBLIC_GISCUS_REPO, PUBLIC_GISCUS_REPO_ID, PUBLIC_GISCUS_CATEGORY and PUBLIC_GISCUS_CATEGORY_ID env vars in your .env file.
  4. Rebuild the site — this notice will be replaced by the live comments thread.

Open giscus.app