Astro für Entwicklerblogs: Shiki & Twoslash
Teil 1 der Serie: So entsteht dieser Blog mit Astro-Collections, Markdown und Codeblöcken mit Shiki und Twoslash. Vom Setup bis zum Praxisbeispiel.
Ein Entwicklerblog braucht gut lesbare Texte und Code, den man verstehen und übernehmen kann. Für diesen Blog schreibe ich Beiträge in Markdown. Astro erzeugt daraus die Seiten; Shiki übernimmt die Syntaxfarben und Twoslash ergänzt bei Bedarf TypeScript-Typinformationen.
Dieser Beitrag ist der erste Teil der Serie “Astro für Entwicklerblogs”. Teil 2 ergänzt Mermaid-Diagramme für Architektur und Abläufe und zeigt, wie sie die Gestaltung dieses Blogs übernehmen.
Hier zeige ich den Stack, die Einrichtung im Projekt und praktische Beispiele aus einem Blog: Entwürfe vor der Veröffentlichung filtern, Release-Notizen erzeugen und Veröffentlichungsdaten typsicher formatieren. Die Codeblöcke lassen sich direkt kopieren und ausprobieren.
Der Stack
Die Aufgaben sind auf wenige Bausteine verteilt:
| Baustein | Aufgabe in diesem Blog |
|---|---|
| Astro | Lädt und validiert Beiträge über Content Collections und erzeugt statische Seiten. |
| Markdown | Beschreibt Texte, Überschriften, Listen, Tabellen, Links und Codeblöcke. |
| Shiki | Erzeugt beim Build die Syntaxfarben für Code. Astro bringt die Integration bereits mit. |
| Shiki-Transformer | Kennzeichnen hinzugefügte, entfernte und hervorgehobene Zeilen. |
| Twoslash | Prüft ausgewählte TypeScript-Beispiele und erzeugt Typinformationen, JSDoc und Diagnosen. |
| Tailwind CSS und Astro-Komponenten | Gestalten Beiträge, Codeblöcke und die Navigation. |
Ein eigener Shiki-Transformer ergänzt Dateititel, Zeilennummern und den Quelltext für Copy. Diese Ergänzungen sind Konventionen dieses Projekts; sie gehören nicht automatisch zu jedem Astro-Blog.
Syntaxfarben, Diffs und Typausgaben entstehen beim Build. Im Browser übernimmt ein kleines Script das Kopieren und die Bedienung der Typ-Popups. Ein weiteres markiert den aktuellen Abschnitt in der Inhaltsübersicht. Für den Text und die Codefarben ist keine hydratisierte UI-Komponente nötig.
Das Setup
Pakete und Projektstruktur
Der folgende Auszug aus package.json zeigt die für den Blog relevanten Pakete.
Die Versionsangaben entsprechen dem Stand dieses Projekts:
{
"type": "module",
"engines": {
"node": "^22.13.0 || >=24.0.0"
},
"scripts": {
"dev": "astro dev",
"check": "astro check",
"build": "astro build"
},
"dependencies": {
"astro": "^7.3.4",
"@tailwindcss/vite": "^4.3.3",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@shikijs/transformers": "4.4.3",
"@shikijs/twoslash": "4.4.3",
"@types/hast": "3.0.4",
"shiki": "4.4.3",
"typescript": "~6.0.3"
}
}Die Dateien für Inhalte, Darstellung und Codeverarbeitung liegen getrennt:
src/
content.config.ts
content/blog/
astro-fuer-entwicklerblogs-shiki-twoslash.md
pages/blog/
index.astro
[...slug].astro
layouts/
blog-layout.astro
components/blog/
blog-index.astro
code-block-controls.astro
lib/
blog.ts
markdown/code-block.ts
styles/
global.cssEin Block ohne Sprachangabe wie dieser Dateibaum bleibt ohne Syntaxfarben. Auch
hier funktioniert Copy. Die englischen Blogseiten liegen zusätzlich unter
src/pages/en/blog.
Die Imports mit @/ verwenden den Alias aus der TypeScript-Konfiguration:
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] },
"ignoreDeprecations": "6.0"
}
}Markdown und Shiki konfigurieren
In astro.config.mjs werden die Transformer an Astros Markdown-Verarbeitung
übergeben. Hier der Blog-Auszug der Konfiguration:
import { defineConfig } from "astro/config"
import tailwindcss from "@tailwindcss/vite"
import {
transformerNotationDiff,
transformerNotationHighlight,
transformerRemoveNotationEscape
} from "@shikijs/transformers"
import { rendererRich, transformerTwoslash } from "@shikijs/twoslash"
import { transformerCodeBlock } from "./src/lib/markdown/code-block.ts"
export default defineConfig({
site: "https://stefan-karger.de",
vite: {
plugins: [tailwindcss()]
},
markdown: {
shikiConfig: {
theme: "github-light",
transformers: [
transformerTwoslash({
explicitTrigger: true,
renderer: rendererRich({
queryRendering: "line",
errorRendering: "line"
})
}),
transformerNotationDiff(),
transformerNotationHighlight(),
transformerRemoveNotationEscape(),
transformerCodeBlock()
]
}
},
i18n: {
defaultLocale: "de",
locales: ["de", "en"],
routing: { prefixDefaultLocale: false }
}
})explicitTrigger: true aktiviert die Typprüfung nur für Fences mit dem Zusatz
twoslash. Gewöhnliche ts-Blöcke bleiben damit auch für unvollständige Ausschnitte
geeignet. Query- und Fehlerausgaben erscheinen direkt im Codeblock.
transformerCodeBlock() ist der eigene Renderer in src/lib/markdown/code-block.ts.
Er übernimmt drei Aufgaben:
- Die Fence-Zusätze für Dateititel und Zeilennummern lesen und den Copy-Button anlegen.
- Nach den anderen Transformern den Kopiertext ermitteln. Entfernte Diff-Zeilen, Twoslash-Ausgaben und Zeilennummern werden dabei ausgeschlossen.
- Typ-Popups neben das scrollbare
presetzen, damit sie nicht an dessen Rand abgeschnitten werden.
src/styles/global.css importiert Tailwind und definiert die gemeinsamen CSS-Tokens.
Das Bloglayout importiert zusätzlich src/styles/blog.css. Diese Datei enthält
die Twoslash-Styles und gestaltet das erzeugte Markdown sowie Diffs und
Hervorhebungen. code-block-controls.astro verbindet die Copy-Buttons mit der
Clipboard-API und ergänzt Hover, Fokus und Antippen für die nativen Popovers.
Dieser Blog verwendet das helle Theme github-light. Shiki erzeugt die
Syntaxfarben beim Build direkt im HTML.
Die Content Collection anlegen
Die Collection lädt .md-Dateien aus src/content/blog. Ihr Schema prüft das
Frontmatter, akzeptiert Kalenderdaten als Strings in YYYY-MM-DD und wandelt sie
in Date-Objekte mit UTC-Mitternacht um:
import { defineCollection } from "astro:content"
import { glob } from "astro/loaders"
import { blogSchema } from "@/lib/blog-schema"
const blog = defineCollection({
// Render through Vite when the page uses the entry, so diagram assets follow
// configuration reloads instead of retaining HTML from the content store.
loader: glob({ pattern: "**/*.md", base: "./src/content/blog", deferRender: true }),
schema: blogSchema
})
export const collections = { blog }Das Schema liegt in src/lib/blog-schema.ts; der Loader verwendet es unverändert:
import { z } from "astro/zod"
import { locales } from "../i18n/locales.ts"
import { uniqueTags } from "./blog-tags.ts"
const date = z.iso
.date({ error: 'Expected a valid date string in "YYYY-MM-DD"; quote dates in frontmatter.' })
.transform((value) => new Date(`${value}T00:00:00.000Z`))
export const blogSchema = z
.object({
title: z.string().trim().min(1),
description: z.string().trim().min(1),
pubDate: date,
updatedDate: date.optional(),
language: z.enum(locales),
tags: z.array(z.string()).default([]).transform(uniqueTags),
draft: z.boolean().default(false),
series: z
.object({
name: z.string().trim().min(1),
part: z.number().int().positive()
})
.optional()
})
.refine(({ pubDate, updatedDate }) => !updatedDate || updatedDate >= pubDate, {
path: ["updatedDate"],
message: "updatedDate must be on or after pubDate."
})Ein neuer Beitrag beginnt beispielsweise so:
---
title: "Veröffentlichungsdaten zuverlässig formatieren"
description: "Warum eine feste Zeitzone Datumsangaben im Blog stabil hält."
pubDate: "2026-10-02"
language: de
tags: ["Astro", "TypeScript", "Astro"]
draft: false
---
Datumsangaben sollen für alle Leser denselben Veröffentlichungstag zeigen.Die Anführungszeichen verhindern, dass der YAML-Parser den Wert vor der
Validierung in ein Date-Objekt umwandelt. Ungültige Kalenderdaten werden
abgelehnt. Ein optionales updatedDate folgt derselben Schreibweise und darf
nicht vor pubDate liegen. Es wird bei einer inhaltlichen Aktualisierung gesetzt.
Aus den Tags werden hier Astro und TypeScript: Leerzeichen, leere Einträge und
Groß-/Kleinschreibungsvarianten werden entfernt; die erste Schreibweise bleibt erhalten. Der Dateipfad bestimmt den Slug. Unterordner sind
ebenfalls möglich; language beschreibt die Sprache des Beitrags.
Beiträge rendern und veröffentlichen
Die dynamische Route erzeugt für jeden veröffentlichten Beitrag eine Seite und übergibt ihn an das gemeinsame Layout:
---
import type { CollectionEntry } from "astro:content"
import BlogLayout from "@/layouts/blog-layout.astro"
import { getPosts } from "@/lib/blog"
export async function getStaticPaths() {
return (await getPosts()).map((post) => ({
params: { slug: post.id },
props: { post }
}))
}
interface Props {
post: CollectionEntry<"blog">
}
const { post } = Astro.props
---
<BlogLayout post={post} />@/ verweist über die paths-Einstellung in tsconfig.json auf src/. Das Layout
ruft render(post) auf und erhält die gerenderte Content-Komponente sowie die
Überschriften. Daraus entsteht die Inhaltsübersicht für h2 und h3. Der aktuelle
Abschnitt wird beim Scrollen fett markiert. Die Übersicht, das Datum, die Tags und
die Links zu benachbarten Beiträgen werden aus den Collection-Daten erzeugt.
Die Route unter /en/blog verwendet dasselbe Layout und dieselben Beiträge.
Beim Sprachwechsel bleiben Inhalt und Slug erhalten; Navigation, Datumsformat und
Copy-Meldungen wechseln die Sprache. Ein deutscher Beitrag wird dabei nicht
automatisch übersetzt.
Im vorhandenen Projekt lässt sich der Ablauf mit diesen Befehlen nachvollziehen:
pnpm install
pnpm dev --background
pnpm check
pnpm builddraft: true hält einen Beitrag während der lokalen Entwicklung sichtbar und
schließt ihn aus dem Produktionsbuild aus. Die Filterung dafür steckt in
getPosts(); die Übersicht und beide Artikelrouten verwenden diese Funktion.
Praxisbeispiele
Entwürfe filtern und Änderungen erklären
Wenn ein Blog zunächst alle Beiträge lädt, können Entwürfe versehentlich in der veröffentlichten Übersicht auftauchen. Das folgende Beispiel ergänzt den Filter und sortiert die Beiträge vom neuesten zum ältesten. Bei gleichem Datum entscheidet der Slug über eine stabile Reihenfolge.
Der Fence verwendet ts title="src/lib/blog.ts" showLineNumbers. Kommentare mit
[!code --] und [!code ++] markieren die Änderung; [!code highlight] hebt die
Sortierung hervor:
import { getCollection } from "astro:content"
export async function getPosts() {
const posts = await getCollection("blog")
const posts = await getCollection(
"blog",
({ data }) => !import.meta.env.PROD || !data.draft
)
return posts.sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime() || a.id.localeCompare(b.id)
)
}Copy übernimmt den neuen Stand. Die alte Ladezeile und die Markierungskommentare fehlen im kopierten Code. Dateititel und Zeilennummern werden ebenfalls nicht kopiert; die Leerzeilen bleiben erhalten.
Ein kurzer Ablauf für die Veröffentlichung lässt sich ganz normal als Markdown schreiben:
- Den Beitrag als Entwurf lokal prüfen.
draftauffalsesetzen.- Typprüfung, Tests und Build ausführen.
Ein erfolgreicher Build prüft auch die expliziten Twoslash-Beispiele. Ein unerwarteter TypeScript-Fehler darin verhindert die Veröffentlichung.
Markdown für Release-Notizen erzeugen
Wer Release-Notizen aus einem Script erzeugt, arbeitet oft mit Markdown, HTML-Zeichen und mehrzeiligen Strings. Dieser Block hat bewusst keinen Dateititel; Copy sitzt direkt oben rechts:
const notes = [
"## Release 1.1",
"",
"**Neu:** Entwürfe bleiben im Produktionsbuild verborgen.",
"",
"```ts",
'const label = "<strong>Blog & Code</strong>"',
"const published = true // [!code ++]",
"```"
].join("\n")
console.log(notes)Der Backslash in [\!code ++] verhindert, dass Shiki die Beispielzeichenfolge
selbst als Diff behandelt. Anzeige und Copy enthalten den wörtlichen Marker
[!code ++]. Anführungszeichen, Backticks, HTML-Zeichen und \n bleiben im
kopierten Quelltext erhalten.
Typen an einer Datumsfunktion zeigen
Eine feste Zeitzone verhindert, dass derselbe Veröffentlichungszeitpunkt je nach Umgebung als anderer Kalendertag angezeigt wird. Die folgende Funktion verwendet deshalb UTC und wählt die Darstellung über die Oberflächensprache.
Dieses Beispiel bündelt Typprüfung, JSDoc, eine dauerhafte Typausgabe, ein
ausgeblendetes Setup und Zeilennummern. Der Fence lautet
ts twoslash title="src/lib/format-post-date.ts" showLineNumbers:
/** Formats a publication date in UTC for the selected locale. */
function (: , : Date) {
return new Intl.(, {
: "long",
: "UTC"
}).()
}
const label = ("de", new ("2026-10-02T00:00:00Z"))function formatPostDate(locale: Locale, date: Date): stringlocale: Localetype Locale = "de" | "en"date: Datevar Intl.DateTimeFormat: Intl.DateTimeFormatConstructor
new (locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) => Intl.DateTimeFormat (+1 overload)
locale: LocaleIntl.DateTimeFormatOptions.dateStyle?: "full" | "long" | "medium" | "short" | undefinedIntl.DateTimeFormatOptions.timeZone?: string | undefinedIntl.DateTimeFormat.format(date?: Intl.FormattableTemporalObject | Date | number): string (+1 overload)date: Datefunction formatPostDate(locale: Locale, date: Date): stringvar Date: DateConstructor
new (value: number | string | Date) => Date (+4 overloads)
Die kleine Locale-Definition oberhalb von // ---cut--- steht nur für die
Typprüfung bereit. Im Projekt wird dieser Typ aus src/i18n/locales.ts importiert.
Die versteckte Definition erscheint weder in der Ausgabe noch im Kopiertext.
// ^? zeigt den abgeleiteten Typ von label dauerhaft unter der Zeile an.
Öffne die Typinformation von formatPostDate per Hover, Tastaturfokus oder
Antippen: Das Popup enthält die Signatur und die JSDoc-Beschreibung. Escape schließt
es wieder. Query-Kommentare und Typausgaben werden nicht mitkopiert.
Ungültige Daten gezielt erklären
Ein Datum aus einer API kommt häufig als String. Die Datumsfunktion erwartet
hingegen ein Date-Objekt. Twoslash kann den Fehler und die passende Umwandlung in
einem einzigen Beispiel zeigen:
const = "2026-10-02T00:00:00Z"
("de", pubDate)("de", new ())const pubDate: "2026-10-02T00:00:00Z"function formatPostDate(locale: "de" | "en", date: Date): stringfunction formatPostDate(locale: "de" | "en", date: Date): stringvar Date: DateConstructor
new (value: number | string | Date) => Date (+4 overloads)
const pubDate: "2026-10-02T00:00:00Z"// @errors: 2345 erlaubt hier ausdrücklich die Diagnose für den falschen
Argumenttyp. Die nächste Zeile zeigt die Korrektur. Die versteckte Deklaration
beschreibt dieselbe Funktion wie im vorherigen Beispiel; beide Codeblöcke werden
unabhängig voneinander geprüft. Copy enthält die beiden Aufrufe als Lehrbeispiel,
aber weder das versteckte Setup noch die Diagnose.
Die Content Collection erledigt eine vergleichbare Umwandlung beim Laden des
Frontmatters nach der Prüfung mit z.iso.date(). So erhält das Layout geprüfte
Daten, während der Beitrag selbst bei einfachem Markdown bleibt.
Vor dem Veröffentlichen prüfe ich mit pnpm test:markdown die Codeverarbeitung und mit
pnpm check und pnpm build das Projekt. Im Browser teste ich Copy, Sprunglinks und
Typ-Popups auch mit Tastatur und schmalem Display. Ohne JavaScript bleiben Texte,
Syntaxfarben, Diffs und die dauerhaften Typausgaben lesbar; Copy wird erst mit
verfügbarer Clipboard-API eingeblendet.