Skip to content
Back to overview

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.

By

Copy codeType information

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:

package.json · Blog-Auszug
{
  "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.css

Ein 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:

tsconfig.json
{
  "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:

astro.config.mjs · Blog-Auszug
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 pre setzen, 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:

src/content.config.ts
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:

src/lib/blog-schema.ts
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:

src/content/blog/datumsformatierung.md
---
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:

src/pages/blog/[...slug].astro
---
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:

Entwicklung und Build
pnpm install
pnpm dev --background
pnpm check
pnpm build

draft: 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:

src/lib/blog.ts
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:

  1. Den Beitrag als Entwurf lokal prüfen.
  2. draft auf false setzen.
  3. 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:

src/lib/format-post-date.ts
/** 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"))
const label: string
function formatPostDate(locale: Locale, date: Date): string
Formats a publication date in UTC for the selected locale.
locale: Locale
type Locale = "de" | "en"
date: Date
var Intl.DateTimeFormat: Intl.DateTimeFormatConstructor
new (locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) => Intl.DateTimeFormat (+1 overload)
locale: Locale
Intl.DateTimeFormatOptions.dateStyle?: "full" | "long" | "medium" | "short" | undefined
Intl.DateTimeFormatOptions.timeZone?: string | undefined
Intl.DateTimeFormat.format(date?: Intl.FormattableTemporalObject | Date | number): string (+1 overload)
date: Date
function formatPostDate(locale: Locale, date: Date): string
Formats a publication date in UTC for the selected locale.
var 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:

Veröffentlichungsdatum aus einer API
const  = "2026-10-02T00:00:00Z"

("de", pubDate)
Argument of type 'string' is not assignable to parameter of type 'Date'.
("de", new ())
const pubDate: "2026-10-02T00:00:00Z"
function formatPostDate(locale: "de" | "en", date: Date): string
function formatPostDate(locale: "de" | "en", date: Date): string
var 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.